Making Cyberglads 7: scoring system

This is a lightly edited transcript of the above Youtube video. It's the seventh part of a series on game development where I'll be building an entire game step by step and sharing the process in public.

Hi and welcome to this seventh installment in the Making Cyberglads series.

I'm building a game called Cyberglads using the Godot game engine.

Ernie, Bert and Kermit

Today I'm going to introduce a new challenge for players in single player mode and a scoring system for Cyberglads.

Before we start let's recap on what we have so far in version 0.1.1. We have an Arena with a head-up display that lets you follow the health of both combatants. The Player and the NPC character duke it out until one of them dies, at which point the player is prompted to play again, which brings them back to the splash page that you see here.

The splash page is the first thing that you see when you load the game, and it lets you pick the NPC you want to play against. We have three NPC's of various difficulty: Ernie, Bert and Kermit. If you've played the game already you know about their respective strengths and weaknesses.

splash page
Ernie, Bert and Kermit are actually vicious trolls

Ernie is slow and weak so he doesn't present much of a challenge. You can just swoop in and start attacking him. He won't be fast or strong enough to put up much of a fight, but at 15 hit points he might be resilient enough to land in a punch or two and make you lose some health before he succumbs to your attacks.

Bert is a lot quicker than Ernie, and also faster than your own player character. Not only does he have speed 400 but he has reflexes of 3.5 and that makes him dangerous. But he's not really that resilient so if you can land enough attacks before he kills you then you're fine. One tactic that I'll call the Stand and Deliver approach is just to sit still and wait for him to reach you and then hit him with all you've got. Remember that the game forces you to respect a certain waiting time in between attacks. Your big club isn't an automatic weapon, you have to raise it above your head and give it a big swing to whack your opponent with it and that takes a bit of time. But if you time your attacks right you should be able to make Bert bite the dust. With this approach though you're bound to lose a few hit points along the way.

Kermit is the toughest opponent. He's not as fast as Bert but he's a lot stronger and more resilient. With his 80 strength and 40 hit points he'll withstand a beating and meanwhile he's going to hit you with his very large club. The trick to beat a stronger opponent at this stage of Cyberglads development is to use the attack delay and the AI think time to your advantage by using hit and run tactics. Get near Kermit, attack him out in the open and then make evasive action before he has time to hit you back. It's not very easy but it's definitely feasible once you get the hang of it. But if you let him corner you where you can't escape you're going to be in trouble.

It's also possible to beat Kermit using the Stand and Deliver tactic, but it's much riskier and you need to time it perfectly.

Three NPC loop

With a little trial and error you'll be able to beat all three NPCs practically every time.

But now I'm going to make the game more interesting in single player mode while still keeping the same three NPC's. Instead of fighting them one at a time I'm going to chain the fights so that you have to beat all three of them in a row. To make it more challenging I'm going to make it so that the player character won't respawn with maximum health between fights, so it will be important to save your hit points for the harder fights.

To do this I'm going to use the global script again to store the value of the player's remaining hit points at the end of each fight.

    
    Open the global.gd script
    Add the following right at the top:
    

    var three_npcs = false
    var temp_health = null
    

The three_npcs flag will just tell us whether we're in the loop where the player fights against each NPC in succession. It will still be possible for the player to just play against a single NPC.

The temp_health variable will hold the remaining hit points before each new fight in the Three NPC loop.

Let's write a function that initializes temp_health to the player's max health, and resets the three_npcs flag to false whenever it's called.

    func set_initial_values():
    	temp_health = get_player_max_health()
    	three_npcs = false
    

We'll call this function in the global script's _ready() function, so we're sure to have these values when the script loads.

    func _ready():
    	set_initial_values()
    

We also need a function that will tell who the next npc is, based on the previous one the Player character faced.

    # during three NPC game loop, get the next NPC in the list
    func move_to_next_npc():
    	if npc_name == "Ernie":
    		npc_name = "Bert"
    	else:
    		npc_name = "Kermit"
    

I need to change the code that animates the life bars when each fight is loaded to check for this temp_health value. I want the life bar to only fill in by as many hit points as the player has at the start of each fight, so that they immediately realise their predicament if they're facing Kermit with just a few hit points to begin with.

    
    In the HUD script _ready() function, replace the following line:

    update_health(player_max_health, "player")

    with:
    

    var temp_health = player_max_health
    # in the Three NPC game loop scenario, the player's starting health is whatever he has left after the previous fight
    if global.three_npcs:
    	temp_health = global.temp_health
    update_health(temp_health, "player")
    

After each fight, if the player is still alive, instead of the "Play again" button I'll show another button to start the next fight and let the player know who he'll be up against.

    
    In the Arena scene, duplicate the 'PlayAgain' button and rename the new node 'PlayNext'.
    Set the text of the PlayNext button to 'Next up: NPC NAME'
    
    

Let's add a function that will let us display the PlayNext button and set its text value.

    
    In the Arena script, add:
    

    func show_PlayNext_btn():
    	$"PlayNext".text = "Next up - " + global.npc_name
    	$"PlayNext".show()
    
next up: Bert
Only 9 HP left and two NPCs to go!

The PlayNext button should just reload the Arena scene with the next NPC in the list and the Player's remaining hit points from the previous fight.

First of all let's make it so that clicking on the PlayNext button just reloads the Arena scene. By duplicating the Play Again button we also duplicated its signal so we'll have to disconnect it first.

    
    Connect a signal from the pressed() event on the PlayNext button to a new function in the Arena script.
    In the Arena script, overwrite the function and add the load_arena() function:
    

    func _on_PlayNext_pressed():
    	# when pressed, continue three NOC game loop
    	load_arena()

    # only called in the context of 3 NPC game loop
    func load_arena():
    	get_tree().change_scene("res://scenes/Arena.tscn")
    

At the end of a fight, there are a few situations we could find ourselves in.

If our three_npcs flag is set to false, it means that we're not chaining fights and we'll show the old Play Again button.

If it's set to true, there are still three cases we need to manage:

  • if the Player character is dead at the end of the fight the three npc loop is over and we should show the Play Again button
  • if the Player character is alive but the last opponent was Kermit, it means that the player survived all three encounters and the loop is finished
  • finally if the Player is alive and the last opponent was anyone but Kermit, we should display the PlayNext button instead of the PlayAgain button and update the npc_name and temp_health variables in the global script.

Let's break this down in the end_of_game() function in the Arena script.

    
    Update the end_of_game() function in the Arena script.
    At the start of the function, remove one line:
    

    show_PlayAgain_btn()

    
    And at the end of the function add:
    

    if not global.three_npcs:
    	show_PlayAgain_btn()
    elif $"TileMap/Player".state == 1:
    	show_PlayAgain_btn()
    elif global.npc_name == "Kermit":
    	show_PlayAgain_btn()
    else:
    	global.temp_health = $"TileMap/Player".health
    	global.move_to_next_npc()
    	show_PlayNext_btn()
    

The first three blocks of this if statement lead to the same result but I kept them separate on purpose. We'll see why a little later.

If we're showing the Play Again button, it means that all the temporary values in the global script should be reset.

    
    In the Arena script, add the following inside the show_PlayAgain_btn() function:
    

    global.reset()

    
    Back in the global script, add:
    

    func reset():
    	set_initial_values()
    

Our reset() function is easy; it just needs to reinitialize the temp_health and three_npcs variables we described earlier.

Finally, we need to trigger the Three NPC loop form the Play button on the splash page. That's easy, we'll just change the function that is triggered by its pressed signal, adding just one line of code.

    
    In the Splash script, modify the _on_PlayButton_pressed() function, adding one line:
    

    global.three_npcs = true
    

I'll also change the button's text value to make it clear what it does now.

    
    Select the Play button in the Splash scene tree view, and in the inspector change its text value to "Play all 3 NPCs"
    
    

And let's make it a little bigger.

So now you have a new objective in our game: beat all three NPC's in a row. Let's playtest it.

It's definitely possible to beat all three NPC's in a row but it's pretty challenging!

Adding scores

Now let's add a scoring system to evaluate how well you've done, even if your character doesn't survive all three fights. I want the score to be a feature of the game itself and to even alter the way players play the game.

The scoring system will take three things into account:

  • Who the opponent was (beating Kermit will be worth more points than beating Ernie or Bert)
  • How many hit points the winner of the fight had at the end
  • And how long the fight lasted

The last two points will count in favor of the player in case of victory but they'll count against them in case of defeat. Specifically, if the player defeats the NPC, the shorter the fight was and the more hit points the player still has at the end, the higher the score. If the player is defeated, the score is better if the fight was longer and if the NPC has few hit points by the end. You can even end up with a negative score if you were easily defeated by an NPC.

a finished game with final score
32 isn't bad but you can do better...

Let's put that into practice.

The score will need to be managed globally since it will be incremented after each fight in the loop. So let's add a total_score variable in the global script.

    
    In the global script, add:
    

    var total_score = 0
    

When the loop ends we'll need to reset the score to 0, so lt's update the set_initial_values() function.

    
    Modify the set_initial_values(), adding:
    

    total_score = 0
    

Now let's go back to the Arena script. We're going to write a function that calculates the player's score for a single encounter, based on the four criteria I mentioned:

  • who won the fight
  • who the opponent was
  • how many hit points the victor had at the end of the fight
  • and how long the fight lasted
    
    In the Arena script, add a new function called get_battle_score():
    

    func get_battle_score(won, npc_name, remaining_health, elapsed_time):
    	var new_score = 0
    	if won:
    		match npc_name:
    			"Ernie":
    				new_score += 5
    			"Bert":
    				new_score += 10
    			"Kermit":
    				new_score += 20
    		if remaining_health > 15:
    			new_score += 5
    		elif remaining_health > 10:
    			new_score += 3
    		elif remaining_health > 5:
    			new_score += 1
    		if elapsed_time < 5000:
    			new_score += 5
    		elif elapsed_time < 10000:
    			new_score += 3
    		elif elapsed_time < 20000:
    			new_score += 1
    		#print("new_score after " + npc_name + " fight: " + str(new_score))
    	else:
    		match npc_name:
    			"Ernie":
    				new_score -= 5
    			"Bert":
    				new_score -= 3
    			"Kermit":
    				new_score -= 1
    		# remaining health is npc_remaining_health in this case
    		if remaining_health < 5:
    			new_score += 5
    		elif remaining_health < 8:
    			new_score += 3
    		elif remaining_health < 13:
    			new_score += 1
    		if elapsed_time < 5000:
    			new_score -= 5
    		elif elapsed_time < 10000:
    			new_score -= 3
    		elif elapsed_time < 20000:
    			new_score -= 1
    	return new_score
    

This function looks complicated but it's actually pretty straightforward. Depending on whether the player won or lost the fight, I'm going to add or remove points based on the opponent's name, the value of remaining_health and elapsed_time. We'll need to pass the correct values of these parameters when we call the function. Notice that I've set some arbitrary thresholds for each parameter. I'm probably going to tweak them in the future.

In the end_of_game() function we already know who won and the name of the NPC. How do we figure out what the winner's remaining hit points are and the length of the fight?

Let's get the remaining health values for both the Player character and the NPC in the end_of_game() function.

    
    In the end_of_game() function, add:
    

    var remaining_health = $"TileMap/Player".health
    var npc_remaining_health = $"TileMap/NPC".health
    var rem_health = 0
    if winner_name == "Player":
    	rem_health = remaining_health
    else:
    	rem_health = npc_remaining_health
    

Our remaining_health parameter should be the Player's remaining health in case of victory, and the NPC's in case of defeat.

For the elapsed time I'm going to start a timer at the start of the fight, by using Godot's built-in OS.get_ticks_msec() function.

    
    At the beginning of the Arena script, add:
    

    onready var begin = OS.get_ticks_msec()
    

In my end_of_game() function, I'm just going to take the delta between this timestamp and another one from the end of the fight.

    
    Back in the end_of_game() function, add:
    

    var elapsed_time = OS.get_ticks_msec() - begin
    

I have everything I need to call my get_battle_score() function.

    
    Still in the end_of_game() function, add:
    

    var score = get_battle_score(winner_name == "Player", global.npc_name, rem_health, elapsed_time)
    

Now that I have the score for each fight, I need to update the value of total_score. Let's add a function that does just that.

    func update_total_score(score):
    	global.total_score += score
    

The players will want to know how well they did so we'll show their score at the end of each fight, and at the end of the Three NPC loop we'll show the total score.

I'm going to add a new RichTextLabel node to my Arena scene. It's already crowded by let's find a corner to show the score at the end of each fight.

    
    Under the Arena scene in the tree view, add a new RichTextLabel node (ScoreLabel)
    Change its font, set the font size to 30
    Move it to the bottom left of the screen and make it wider
    Ste its text property to "Score:"
    Set its default visibility to invisible
    
    

And now let's add two new functions to populate the text property of this new label node either at the end of a fight or at the end of the Three NPC game loop.

    
    Back in the Arena script, add:
    

    func show_score_current_match(score):
    	$"ScoreLabel".text = "Score: " + str(score)
    	$"ScoreLabel".show()

    func show_total_score(score):
    	# todo: we should also show the score of the last match (it's never shown, we just show the final score)
    	$"ScoreLabel".text = "Final score: " + str(global.total_score)
    	$"ScoreLabel".show()
    

And now all I need to do is call the update_total_score() and the show score functions at the end of my end_of_game() function.

    
    Replace the end of the end_of_game() function with:
    

    if not global.three_npcs:
    	show_score_current_match(score)
    	# make button appear in GUI to play again
    	show_PlayAgain_btn()
    elif $"TileMap/Player".state == 1:
    	# if the player died in this fight, the three NPC game loop is over, calculate final score and ask to play again
    	update_total_score(score)
    	show_total_score(score)
    	show_PlayAgain_btn()
    elif global.npc_name == "Kermit":
    	# otherwise if the NPC who died is Kermit, the game loop is also ended, calculate final score and ask to play again
    	update_total_score(score)
    	show_total_score(score)
    	show_PlayAgain_btn()
    else:
    	# otherwise it means that the game loop is still active, increment the score and reload the arena with the next NPC
    	show_score_current_match(score)
    	update_total_score(score)
    	global.temp_health = $"TileMap/Player".health
    	global.move_to_next_npc()
    	show_PlayNext_btn()
    

This end_of_game() function is getting quite complicated, we'll need to refactor it at some point.

But for now, as I mentioned this scoring system by itself should change player tactics. If your objective is just to beat all three NPCs then you can ignore the scoring system. But if your objective is to beat other players' score or land a score on the leaderboard then you have to try to reduce fight times and try not to lose too many hit points during fights. On the other hand if say you can't manage to beat Kermit after beating the other two NPC's, one way to improve your score is to try to make the Kermit fight last as long as possible. If you're playing the clock you might pick a defensive, risk averse strategy.

Let's playtest the loop again and see how high I can score.

Building a good score system for a game isn't a big technical challenge in and of itself. It's more a matter of choosing the right behaviors and goals to reward. If your players optimize for the best score, it should also make the experience more enjoyable.

Dave Thier's piece about scoring systems in Fortnite goes into how tweaks to scoring systems in a major game can impact player incentives and therefore player behavior.

While you're reading up on scoring systems, you might want to draw inspiration from outside the realm of video games. Teale Fristoe wrote an insightful article a few years back about score systems in tabletop games that you might want to check out.

And finally Keith Burgun wrote an interesting piece on Gamasutra about why in his opinion we should be avoiding score systems entirely.

That's it for this edition of Cyberglads. As always you can download the latest version of the game on the cyberglads.com website

If you try it out I'd love to hear about your tactics for beating all three NPCs one by one and then one after the other.



Never miss a blog entry! Enter your email address here to subscribe to the Cyberglads newsletter: