Super Municipal Property Tax Simulator 2000

Today's article is weird. It introduces a new (and web-playable!) game I'm working on, the rational and philosophy behind it, as well as a detailed Godot 4 tutorial that outlines my process step by step.

So first off, I guess I'm returning to my roots in educational game development, a subject on which I wrote my Master's thesis, and the period of my career in which I produced Super Energy Apocalypse and Cellcraft. I'm quite proud of these two games to this day, and I just recently discovered that thanks to the magic of Ruffle, those ancient titles are finally playable again, right in your browser, today. So check out the links if you want to see what I was capable of seventeen years ago.

The comic below captures the uplifting sentiment I feel at this moment, which also happens to perfectly depict the expectations you should set for any final results:

Now don't get me wrong, I'm not going back to being a professional game developer. I'm just wasting my free time on a stupid little project. I have no idea how long I'll continue with it. But it's been fun, so let me tell you about it!

What is it?

In the land of Geldheim, property taxes are levied by two separate but equally important groups: the property assessors who calculate fair market values, and the elected officials who set tax rates upon them. These are their stories.

Dun-dun!

You are a newly minted apprentice property assessor. Your sacred duty is to accurately assess tens of thousands of real estate parcels at prevailing market rates, showing neither partiality nor favor. You must learn the same tasks that property assessors do in the real world, but with much higher stakes–for your very life depends on achieving the three goals of accuracy, consistency, and fairness. Assess properties too high, and a mob of angry citizens will descend upon your office come protest season. Assess properties too low, and the fierce jaws of His Eminent Grumpiness, the Imperial CompTroller, will swallow you whole.

Survive your training and you will be promoted to the rank of Journeyman where you must not only value property but also venture out into the field to collect fresh data and ultimately map your entire jurisdiction using GIS technology. Excel in this, and you may be promoted to the coveted rank of Master Assessor, wherein you will take command of a whole county, and ultimately an entire province. Excel further, and the Emperor himself might elevate you to the rank of Imperial Overseer, sending you on a tour of the whole kingdom, visiting each province one by one, solving untold data quality and methodological irregularities.

(Hat tip to Patrick McKenzie for the opening line).

That's the inspiring vision at least. Probably I'll get about 1% there. But that's fine!

Why am I doing this?

First, I'm 41 now, and right on schedule for a mid-life crisis. Time to try in vain to recapture the energy and vigor of my misspent youth!

Second, my misspent youth is the period in which I originally produced Super Energy Apocalypse and Cellcraft and I'd love to revisit that particular school of design. I've always thought felt that games which incorporate real world research, expertise, and ideas are particularly unique and interesting. Too many games are just inspired by other games. Also, back then I had no idea what I was doing, and that naïve lack of pretension was useful in getting things done quickly.

I'm gonna chase that feeling.

Third, I've also always been fascinated by games like Minecraft, Factorio, and the Zachtronics portfolio, and want to try something kind of in that direction, with my own personal spin. I want to ultimately create something open-ended and sandboxy.

Fourth, since I'm no longer a professional game developer, I don't have to limit myself to projects that could credibly feed my family or appeal to a mass audience. I'm free to work on whatever stupid idea that interests me.

Fifth, the last time I spent some time trying this kind of thing, I really enjoyed it. That's when I made Tourette Quest. I was really happy I did it and got it out there in the world even though it's janky and broken and stupid. I still get nice emails about it to this day from people who found meaning in it.

Sixth, I figure it's about time I started to learn Godot, the open source game engine I always wish had existed when I first started making games.

Seventh, my day job is real estate mass appraisal, so I have fresh domain knowledge and professional expertise to draw on. Who knows? If I keep building this slowly over time, it might just turn into a not too shabby form of on the job training software. Maybe at some point it will start to blend directly with OpenAVMKit, the free and open source library that the Center for Land Economics and I maintain for mass appraisal.

Get Ready to be Underwhelmed

Okay, now that I've hyped you all up, time to massively let you all down.

Bask upon the incredible glory of version 0.0.0.0:

You can even play it right now on Itch.io!

Super Municipal Property Tax Simulator 2000 by larsiusprime
Experimental thingy

Spoilers below:

.

.

.

.

.

.

In this simplest possible version of the game, all you do is look at three houses and try to guess the central price tendency. The prices are all abstract and its not clear what the unit of account even is, and also all the properties are exactly the same (everything is identical about them, including the location). You just guess the proper valuation, which in this case is the number in the middle.

For those of you who have read my article Mass Appraisal for the Masses: the Basics, over at the Progress & Poverty substack, you will recognize this as the dead-simple video game embodiment of one of the opening examples.

All you're really doing here is calculating the median, but that's enough for a simple, and more importantly, complete game loop. This is the tiny grain of sand upon which I will slowly build a shining pearl.

Now, let's forget about property tax assessment for a second and discuss another thing I'm doing here.

I've learned some hard lessons over the years, and one of them is that it's very easy to get trapped in development hell even when you have professional experience and especially when you think you already know better. I made mistakes. All the mistakes. I even knew I was making the mistakes as I watched myself make them! Everything that contributed to Defender's Quest 2's decade-long slog was entirely my fault, I had multiple chances to stop making mistakes, and it's only thanks to the heroic efforts of people other than me that the game winded up shipping at all, a game I'm proud of despite all the hardship and difficult memories I can't help but associate with it, mostly because of the manner in which my involvement with it ended (it's not for the faint of heart, but you can click here and here if you really want to know).

I am forever grateful to the team that made it possible, particularly Patrick McCarthy, Anthony Pecorella, Xalavier Nelson, Alexey Zelikin, Kevin Penkin, everyone at Armor Games, and everyone else in the credits list. You should hire these folks because they are all incredibly talented and hard working.

I'll save going over my own personal mistakes for another day. Now that I'm starting a fresh project, and knowing my traditional weaknesses and failure modes all to well, I am setting some strict rules for myself for my dumb, no-stakes personal project.

The Rules

RULE 1: I have nothing to prove
Nobody is paying me to make this game. I am not doing it as a job. I am not taking preorders, I will not do a Kickstarter. I'm even doing my best to suppress any premature thoughts of putting it on Steam. I can quit any time. I'm mostly doing this for me.

RULE 2: Always simple, complete, and playable
The game must begin in a playable state, and every time I touch it, it must end in a playable state. There must always be a complete game loop, defined as:

  • A task to do
  • A win condition
  • Feedback
  • Ability to proceed

RULE 3: Iteration must always produce a noticeable change
This is a personal limitation to safeguard against my own tendency for overcomplicating things with speculative stuff when YAGNI (You Ain't Gonna Need It). Whenever I touch the project, there needs to be something new that is noticeable, even if it's small. A concrete change that is visible to the player has to happen. This forces me to keep my changes small and concrete and focus on stuff that matters.

RULE 4: No refactoring until I have a specific need
This is a natural extension of rule 3, but I call it out specifically to remind myself.

RULE 5: Improve existing experiences before proceeding to new ones
This is a game about property tax assessment, which is approximately the most boring sounding idea in the whole world. My job is to make it feel not just important, but also fun and satisfying. The only way to do that is one step at a time. I will make one single simple task as quickly as possible. Then I will make it fun and joyful and interesting. I won't move on to the next thing until I've achieved that.

The opposite approach is to try to build the whole dang ambitious simulation all at once, and then make it fun, but that doesn't seem like it would work at all.

With that out of the way, let's describe in detail how I built the first super stupid simple version of this game, and then how I made it kind of fun and interesting. I'll keep repeating this loop and document it in future articles until I either decide not to anymore, or until the result becomes interesting enough to start thinking about actually shipping it for real.

Let's start with building the proof of concept.

Building version 0.0.0.0

My main tools were just Godot 4, the official documentation, and ChatGPT, which I would simply prompt with "How do I do X in Godot 4," which got the right answer about 85% of the time (It's really important to specify Godot version 4 or it often gives you outdated advice). I wrote the vast majority of code myself, but for a few animation functions I copy and pasted a little ChatGPT generated code. Other than that I just used ChatGPT as a more intelligent version of Google search.

As for the development itself, I won't describe each and every step in exact detail down to the mouse clicks, but anyone who has done a few basic Godot exercises should be able to follow along. Let's go!

My initial file system looks like this:

And my main scene tree looks like this:

A couple things to point out:

  • I only have one scene, Main, which is set to auto load when the game beings. Rule 2 (keep it simple).
  • Everything is just thrown right onto the main stage with no hierarchy or planning/organization. Rule 2.
  • house1, house2, house3, are all instances of the same object. This is just straight-up hardcoded. I will surely want to eventually make this dynamic and flexible–but not yet! Rule 4 tells me to hold off for now.
  • I have two sound effects embedded directly as their own dedicated AudioStreamPlayer instances, which is technically a bit inefficient. But who cares? I'll deal with it later. I just need basic feedback sounds for now, so I will hold off on making an actually flexible sound playback manager. Rule 2/Rule 4.

As for the assets themselves, the graphics are based on Google Noto Color Emoji glyphs, which are available under an open license. Sound effects are generated in JSFXR (itself a variant of Dr. Petter's original SFXR), or recorded by myself in Audacity.

Everything is laid out on the main scene like this:

I have some text at the top for putting instructions in, I have three custom objects for my "house prices" that do little more than display a custom number. Then there's a numeric stepper (which Godot calls a "SpinBox"), and a button to submit your answer. Then there's a text field for displaying feedback to the player on the bottom, and a button to press to reset the game state (in this case, refresh the scene with a new challenge).

That's a minimal, fully contained "game" right there. It's small, it's simple, and it's stupid, but it's a complete experience. This is the "first playable" of the game, and Rule 2 is fulfilled.

Eagle-eyed readers might even have noticed that the scaling on the SpinBox and the Submit/Continue buttons is super lazy and ugly – I just set the scale transform to 2.0, rather than doing it "the right way" by adjusting the font size of those components. I knew at the time I was probably not doing it the right way, but–who cares? Rule 1, nothing to prove. I decided to just get the first playable done and figure the rest out later.

Now, let's dive a bit into the code. There are exactly three scripts in this project:

  • house_price.gd
  • main.gd
  • math_utils.gd

These are all written in Godot's native scripting language, GDScript, which is essentially just python with the serial numbers filed off.

house_price.gd

We'll start with house_price.gd, which gives functionality to our main custom component, the little house icons with prices attached. Here it is in its entirety:

extends Node2D

@export var price:= 100
@onready var label_price: Label = $label_price

func _ready():
	reset()

func reset():
	price = randi_range(80, 120)
	$label_price.text = "%.0f" % price

The top line, "extends Node2D" makes the object inherit all the properties of Node2D, which is somewhat analogous to a "Sprite" in GameMaker, Flixel, or Flash. And for those of you who actually remember Flash, whenever I say, "Scene" in Godot, these are mostly analogous to Flash's Movieclips.

The next two lines declare member variables for the script. The @export decorator means that every time you drop one of these components on your stage in the editor, you will be able to expose that variable as something you can directly edit, in the side panel, like this:

Next let's look at this line:

@onready var label_price: Label = $label_price

Here's what this does:

  • @onready ensures that the assignment doesn't happen until the house_price scene itself is initialized
  • var just declares a variable
  • : Label declares a type for the variable, which will throw errors if we violate them later (i.e., assigning something of another type to this variable). This is optional.
  • $label_price anything with $ syntax specifically refers to an asset in our scene tree. So what we're doing here is creating a pointer variable to this particular label asset in our scene tree, so we can do stuff with it in code later. When you nest objects inside hierarchies, you'll reference them using the $ syntax and forward slashes like this: $path/to/scene/tree/object.

In short, we declare a variable that will point to an object of type Label. When the main scene is initialized and all its assets are ready, we point it at the object identified as $label_price in our scene tree hierarchy. From now on we can invoke label_price.<something> to access both member variables and functions for that particular instance.

Now the meat of the script:

func _ready():
	reset()

func reset():
	price = randi_range(80, 120)
	$label_price.text = "%.0f" % price

_ready() is a built in function that is called when the scene has finished initializing. All we're doing here is calling reset(), which simply picks a random number between 80-120, and displays that in our label. We also store that in the price variable.

From the looks of it I goofed and didn't even wind up using the label_price variable I had set up for myself, I just invoked the raw asset directly! It's not a big deal because in this case it does the same thing. Oh well, who cares. I'll fix it later. Let's look at the text assignment:

$label_price.text = "%.0f" % price

Here I am writing formatted text to the label so it will display. The formatting system is similar to formatted strings in python and C, but has its own quirks, so I recommend reading up on the documentation. In any case, this prints the number with no decimal places.

And that's it! That's all the house_price scene does. It displays a randomized number, and you can poke it to make it do that again.

math_utils.gd

This one is pretty simple too. It's just one global function for convenience:

class_name MathUtils

static func median(arr: Array[float]) -> float:
	if arr.is_empty():
		push_error("median() called with an empty array")
		return 0.0
	var sorted := arr.duplicate()
	sorted.sort()
	var mid := sorted.size() / 2
	return sorted[mid] if sorted.size() % 2 else (sorted[mid - 1] + sorted[mid]) * 0.5

I stick class_name at the top so that I can invoke it from anywhere in my code, then define a static function, which means anytime I want from now on I can call MathUtils.median() from anywhere in my codebase.

This is just a poor man's version of the kind of calls you could make in numpy in Python land. Given a list of floating point numbers, return the median value. Maybe there's a better way to do this but I don't care right now.

main.gd

This is the actual meat of the game. Here's the main script.

extends Node2D

@onready var l_instructions = $instructions
@onready var l_answer = $your_answer
@onready var l_feedback = $feedback
@onready var n_guess = $guess

@onready var house1 = $house1
@onready var house2 = $house2
@onready var house3 = $house3

@onready var good = $good
@onready var bad = $bad
@onready var btn_continue = $continue

var true_value = 100
var right_answer = false

func _ready():
	reset_scene()

func reset_scene():
	l_instructions.text = "All properties are identical.\nWhat is the correct valution?"
	l_feedback.text = ""
	house1.reset()
	house2.reset()
	house3.reset()
	true_value = MathUtils.median([house1.price, house2.price, house3.price])
	right_answer = false
	btn_continue.visible = false

func _on_submit_pressed() -> void:
	var answer = n_guess.value
	if answer == true_value:
		l_feedback.text = "That's right!\nClick here to continue:"
		right_answer = true
		btn_continue.visible = true
		good.play()
	else:
		l_feedback.text = "That's wrong!"
		right_answer = false
		btn_continue.visible = false
		bad.play()

func _on_button_pressed() -> void:
	if right_answer:
		reset_scene()
		btn_continue.visible = false

Let's go through it bit by bit:

extends Node2D

@onready var l_instructions = $instructions
@onready var l_answer = $your_answer
@onready var l_feedback = $feedback
@onready var n_guess = $guess

@onready var house1 = $house1
@onready var house2 = $house2
@onready var house3 = $house3

@onready var good = $good
@onready var bad = $bad
@onready var btn_continue = $continue

First, we extend Node2D, and declare a bunch of variables. Note as mentioned above how we are hard-coding exactly 3 house_price objects as $house1, $house2, $house3. Yes it's sloppy! But whatever!

var true_value = 100
var right_answer = false

func _ready():
	reset_scene()

func reset_scene():
	l_instructions.text = "All properties are identical.\nWhat is the correct valution?"
	l_feedback.text = ""
	house1.reset()
	house2.reset()
	house3.reset()
	true_value = MathUtils.median([house1.price, house2.price, house3.price])
	right_answer = false
	btn_continue.visible = false

We declare a true_value variable, which is the thing we're trying to guess, and initialize it to 100. Then we set a flag for if we have the right answer or not.

On _ready(), we reset the scene, same as we do inside house_price.gd.

As for reset_scene(), we set the instructions and clear the feedback line. Then we reset all three houses, which re-rolls all their prices. We calculate a fresh true_value by taking the median of the three house's displayed price values. We set the right_answer flag to false, which will prevent us from continuing later until we set it to true. Then we hide the continue button.

Next, I have two functions to respond to events:

func _on_submit_pressed() -> void:
	var answer = n_guess.value
	if answer == true_value:
		l_feedback.text = "That's right!\nClick here to continue:"
		right_answer = true
		btn_continue.visible = true
		good.play()
	else:
		l_feedback.text = "That's wrong!"
		right_answer = false
		btn_continue.visible = false
		bad.play()

func _on_button_pressed() -> void:
	if right_answer:
		reset_scene()
		btn_continue.visible = false

When you click the submit button, the first one gets called. Your answer is grabbed from n_guess (the SpinBox), and if it matches the correct answer, we write out positive feedback, trip the right_answer flag, show the continue button, and play a happy sound effect. Otherwise we do the opposite.

When you click the continue button, if and only if the right answer has been achieved do we reset the scene, which would show another challenge. Technically I could have controlled this just by making the continue button visible/invisible without an additional flag, but whatever.

Now, how did I actually associate those buttons with these callbacks? Godot lets you do this in one of two ways–you can either directly connect functions to event signals with code, or you can do it in the editor. In this case I did it in the editor:

One thing I really like about Godot is that everything in your project is just a file, and unless it's a multimedia asset, it's almost always rendered as good ol' plain text. So even when I'm doing stuff like clicking things directly in the scene graph, it still results in a human-readable piece of text that encodes that information in a way that's perfectly legible to a code editor, and more importantly, to your version control system. For instance, these signal connections I just made are visible in the bottom of the main.tscn file:

[node name="continue" type="Button" parent="."]
offset_left = 496.0
offset_top = 576.0
offset_right = 575.0
offset_bottom = 607.0
scale = Vector2(2, 2)
text = "Continue"

[connection signal="pressed" from="submit" to="." method="_on_submit_pressed"]
[connection signal="pressed" from="continue" to="." method="_on_button_pressed"]

And that's it for our first playable! I commit it to source control and move on with my life.

It's simple, and functional, but it's not very fun. What can I add?

Make it less worse

Hmmm, what to add next? I can think of a couple things.

Let's:

  1. Add some level progression, so the tasks get harder over time.
  2. Make the houses dynamic so there's not always exactly three.
  3. Give the player some powerups.
  4. Add powerups to the overall progression.
  5. Add juice and oil.

Cool, let's knock out that list one by one.

#1: Level Progression + Range

We're going to do the absolute bare minimum to add any level progression at all. Right now the guessing game is always using the same range. Let's make the range expand over time.

First, I add a new label at the top of the screen indicating the current level:

I'll add that label as l_level in my main script:

@onready var l_level = $level

Next I'll add some variables to keep track of the level we're on. As the levels increase, I'm going to increase the range of variation in prices. In the previous version, range was hard-coded into the house_price objects themselves. Now I'm going to refactor that out to being a property of the main scene. I didn't need it before, but now I do (Rule 4).

var level = 1
var stage = 1

var range = 0

I update house_price.gd so that I have to pass in a range when I reset it:

func reset(range):
	var base_price = 100
	price = randi_range(base_price-range, base_price+range)
	$label_price.text = "%.0f" % price

The implicit base price stays the same at 100. I might want to change that later, but not yet! (Rule 4).

Next, I need some way in the main scene to know what should change when the player gets to a different level. In a professional project with dedicated level designers, I would want to store this in a text or JSON file so that a colleague could edit it, but a) it's only me here, and b) it's too early (Rule 4!), so let's just hard-code it:

	
func get_level_info():
	if level == 1:
		if stage == 1:
			range = 0
		elif stage <= 3:
			range = 1
		elif stage <= 5:
			range = 5
		elif stage <= 10:
			range = 10

I have a two-stage level hierarchy of "level" and "stage" which itself was probably a bit premature, but whatever, moving on, I'll fix it later. The effect of level progression is simply to increase the range of price variation. I'll add other changes soon enough, sticking to this one simple minimal change for now (Rule 3).

Next, I need to update reset_scene():

func reset_scene():
	get_level_info()
	l_level.text = "Level " + str(level) + "-" + str(stage)
	l_instructions.text = "All properties are identical.\nWhat is the correct valution?"
	l_feedback.text = ""
	house1.reset(range)
	house2.reset(range)
	house3.reset(range)
	n_guess.max_value = max(house1.price, house2.price, house3.price)
	n_guess.min_value = min(house1.price, house2.price, house3.price)
	n_guess.value = 0
	n_guess.get_line_edit().clear()
	true_value = MathUtils.median([house1.price, house2.price, house3.price])
	right_answer = false
	btn_continue.visible = false

The first thing I do is call get_level_info() which updates the range variable based on whatever level I'm at right now. Then I set the instructions and feedback as before, but now I also update the label that displays the current level. Then, I reset the house_price objects, but I'm now feeding them a range. Also, I'm starting to mess around with the numeric stepper a bit now. I set the max and min legal values to the max and min observed house price values. I also am trying to make it "blank" by setting the initial value to 0 and then clearing the displayed value, which will wind up having mixed results, but mostly works. The rest is the same as before.

Finally, one small change to on_submit_pressed() , I add stage += 1 to the correct answer block, to advance the player to a slightly harder challenge.

func _on_submit_pressed() -> void:
	var answer = n_guess.value
	if answer == true_value:
		l_feedback.text = "That's right!\nClick here to continue:"
		right_answer = true
		btn_continue.visible = true
		stage += 1
		good.play()
	else:
		l_feedback.text = "That's wrong!"
		right_answer = false
		btn_continue.visible = false
		bad.play()

That's it. All it does is add some basic progression. The game is still pretty basic, but at least now it's not the same challenge over and over again.

#2: More Houses

It's finally time to do a little refactoring, because the next thing I want to do is make it possible for there to be more than three houses. When you get to certain levels, we'll increase the houses to five, seven, and even nine.

We don't want to hardcode all those houses, so we need to create them with code and do that in a flexible way.

The first thing we do is we update the main scene. Instead of three house_price objects, we now just have a container for them:

The houses object is specifically of the HBoxContainer type. These layout doodads take a little time to get used to, but they're really powerful because they eliminate your need to automatically calculate positioning in code. Instead, you just add children to them and the children are forced to follow the special layout rules of the container. In the HBoxContainer, for instance, you specify center alignment and spacing on the container, and then whenever you add and remove children to it, they just follow those rules without you having to move them around manually.

The next change I make is to house_price.gd, and to the corresponding house_price.tscn scene. I change the type of the scene VBoxContainer, and make the script inherit from the same instead of Node2D like before:

class_name HousePrice

extends VBoxContainer

Then, I need to update some code in the main.gd script. This is the single biggest change.

First, we need to add a variable for the houses container:

@onready var houses = $houses

Next, I need to be able to instantiate a house_price object at will. There are two easy ways to do this: either have a code reference to the scene and instantiate that, or place an (invisible) object of the desired type on stage and duplicate it as needed. We'll be using the first method, and to prepare for that I need to preload the house_price scene. We do that like this:

@onready var HouseScene : PackedScene = preload("res://scenes/house_price.tscn")

This just points a variable of type PackedScene at the result returned by the preload() function, which we feed the path to our scene's file. Calling preload() will front-load the asset fetching overhead so it doesn't happen in the middle of the game, but you can call load() if you want it to happen right when you call it instead. In a more complicated game you will need to pay attention to loading times and be smart about it, but for now it doesn't much matter.

In any case, from now on we can create instances of this scene any time we want.

var max_stage = 8

var n_houses = 3
var list_houses: Array[HousePrice] = []

I add some more variables; at this point I have the idea of rolling over to the next "level" with its own set of stages, and decide that happens at stage 8.

Next, we need to keep track of how many houses we have. We need a number to create, and an array to keep track of them now that we don't have hard coded references.

Now, we need to update our initialization function to start us off with three houses:

func _ready():	
	for i in range(3):
		add_house()
	reset_scene()

We're going to have to define what add_house() does:

func add_house():
	var house = HouseScene.instantiate()
	list_houses.append(house)
	house.reset(range)
	houses.add_child(house)

This instantiates a house_price from the scene we preloaded, adds it to our list_houses array so we can keep track of it, calls reset() on it with the current range, and then calls houses.add_child() which adds that particular object to our horizontal container in the main scene. The array lets us keep track of the objects as data, while add_child() adds them to the scene as on-screen visual elements.

Next, we need to update our get_level_info() function to change the number of houses. I'm also going to add a next_stage() function that will replace just upping the stage counter by one, so we can roll over to level 2 after stage 8:

func get_level_info():
	if level == 1:
		max_stage = 8
		n_houses = 3
		if stage == 1:
			range = 0
		elif stage <= 3:
			range = 1
		elif stage <= 5:
			range = 5
		elif stage <= 8:
			range = 10
	if level == 2:
		max_stage = 8
		n_houses = 5
		if stage == 1:
			range = 10
		elif stage <= 3:
			range = 20
		elif stage <= 5:
			n_houses = 7
			range = 30
		elif stage <= 8:
			n_houses = 9
			range = 40
			
func next_stage():
	stage += 1
	if stage > max_stage:
		stage = 1
		level += 1

Next, we need to update the reset_scene() function to actually update the number of houses on screen if the current level progression calls for it.

func reset_scene():
	get_level_info()
	while n_houses > list_houses.size():
		add_house()
	l_level.text = "Level " + str(level) + "-" + str(stage)
	l_instructions.text = "All properties are identical.\nWhat is the correct valution?"
	l_feedback.text = ""
	
	var prices:Array[float] = []
	for house in list_houses:
		house.reset(range)
		prices.append(house.price)
	
	n_guess.max_value = prices.max()
	n_guess.min_value = prices.min()
	n_guess.value = 0
	n_guess.get_line_edit().clear()
	true_value = MathUtils.median(prices)
	right_answer = false
	btn_continue.visible = false

We add a while loop up top that adds new houses until the number called for matches the number we have.

Then, we add a new block where we gather the prices from each house into an array, rather than gathering them from the old hard coded references, and feed them into the median calculation.

The last change is to replace the old stage += 1 on the submit button response to invoke next_stage():

func _on_submit_pressed() -> void:
	var answer = n_guess.value
	if answer == true_value:
		l_feedback.text = "That's right!\nClick here to continue:"
		right_answer = true
		btn_continue.visible = true
		next_stage()
		good.play()
	else:
		l_feedback.text = "That's wrong!"
		right_answer = false
		btn_continue.visible = false
		bad.play()

This will ensure our level progression logic catches the rollover once we get past stage 8.

Great – now we have nice level progression, and when we get to level 2, we jump from 3 houses to 5!

Woo.

A brief interlude

Believe it or not, I actually have a vision of where I'm eventually going to take the player, even if the gameplay so far seems too simple and stupid to matter.

What I'm actually doing is secretly training the player to fill out a Uniform Residential Appraisal Report. These can be kind of intimidating to people who have never seen them before:

This will be one of the many tasks the player winds up doing in the full game, but even this by itself is too intimidating to start with. The way you actually fill these out in real life is that you find a property you want to come up with a value for (the "Subject"), and then you find three physically similar properties in the same local area (the "Comparables") that have sold recently. You make price adjustments based on all the differences between each comparable and the subject property, come up with an "adjusted price" for each comparable, and then based on that, demonstrate that your valuation for the subject is within the range of the adjusted sale prices of the comparable sales.

That's all too complicated to deal with for now (there's entire books that could and have been written on the subject), so for now we are stripping away all that extra complexity. But we'll get there, slowly, adding things up one by one, until we're all the way there. And once they've mastered that one task, we'll send the player off to complete other tasks around their office, until the fateful day when they must face off against the big boss–the strict and fearsome Comp-Troller, and his intimidating annual Property Value Study.

#3: Power ups!

Our poor apprentice property assessor is carving median values out of stone with a hammer and chisel, let's give the poor sap some actual tools. We'll frame these as power ups that we dole out as periodic rewards. It always feels great to get nice tools that make your job easier!

What are some ways we could do that in a way that is fun and interesting?

Well, for one, we could just give them a "median" button that automatically calculates the median value from a range of numbers and sets that as the guess. Here's a quick icon I whipped up for that:

However, I don't think we should give this one to the player right away. Let's give them a simpler tool, first–sort:

We'll always show an odd number of houses, and when you sort them, the middle number will always be the median value. That's the smallest change that will be immediately useful and feel like an upgrade.

We don't even have to explicitly tell the player that their goal is to guess the median value. We can let them figure that out on their own. To smooth this process, we restrict the early guessable range and then drop this tool in later. Attentive players will probably pick up on these hints, even before we've added a single tutorial popup. How much we lean on overt tutorialization will depend on playtesting results, but it's always nice to keep that to a minimum.

Sort and median are the only tools we need for now. Let's think a bit about how they'll work.

I think I want tools to unlock one by one as a natural part of level progression. Eventually I can accompany this with a nice bouncy "You got a <whatever>! This lets you do <thing>!" kind of celebratory popup, but–Rule 4–we ain't gonna do that yet.

For now I think all I need for minimal implementation is:

  • Tools will be simple buttons you click on.
  • Clicking on them invokes their power.
  • Each button is a simple square icon with a tooltip label that appears on hover.
  • Buttons live in a sidebar UI element called the "toolbox."

Later I'll do some playtesting and figure out if I need to make them more clear than that, but for now I'll just focus on making them appear and function at all.

Now that we've figure out how they'll work, let's add them.

#4: Tools gained at specific Stages

First, I create a new scene called "toolbox" and add it to my main scene.

The toolbox scene itself is very simple: just a simple UI panel stretched vertically, accompanied by a VBoxContainer, which we'll use to hold our individual buttons. We can set spacing rules in the container and then we just need to call add_child() to drop our buttons in one by one.

Next, I need to create a tool_button scene for the actual tool powers themselves. This is really simple, I just use the existing Button node as the base type:

I go ahead and give it a basic size. Then I set a default icon in the inspector, using the "Quick load" option, and pointing it to the file path of the "sort" image asset:

That's just so I can get a sense for how any icon will look. When we instantiate these for real we'll have each button load a custom user-defined image. That means we need a script, so I attach a script to the root button node, tool_button.gd :

class_name ToolButton

extends Button

@export var tool = "sort"

func _ready():
	custom_minimum_size = Vector2(64, 64)
	refresh()
	
func refresh():
	tooltip_text = tool
	var path = "res://images/tool_"+str(tool)+".png"
	icon = load(path)

func set_tool(value):
	tool = value
	refresh()

This is pretty straightforward. I give it a class_name so that I can later instantiate it directly. I also give it an exposed variable for what kind of tool it is.

On initialization, I set the built-in custom_minimum_size variable which means it will never shrink below 64x64, and then I call refresh(), which updates the icon. refresh() itself attempts to load an image asset corresponding to the name of the tool. There's no safety checks there, so if you feed it something there's no asset for it will probably crash. Finally, I expose a function that lets you change the icon, which will also trigger a visual refresh. I haven't yet figured out how to do this in a super "proper" way where it will also auto-update in the editor, mind you. This is just some minimum cheap thing to get this working at all.

The next thing I need to edit is main.gd , to add things to the toolbox over time.

First, add a new variable for the toolbox:

@onready var toolbox = $toolbox

Next, add some drop points for the tools. I decide that level 1-5 will drop "sort" and level 2-5 will drop "median."

func get_level_info():
	if level == 1:
		max_stage = 8
		n_houses = 3
		if stage == 5:
			get_tool("sort")
		
		if stage == 1:
			range = 0
		elif stage <= 3:
			range = 1
		elif stage <= 5:
			range = 5
		elif stage <= 8:
			range = 10
	if level == 2:
		max_stage = 8
		n_houses = 5
		if stage == 5:
			get_tool("median")
		
		if stage == 1:
			range = 10
		elif stage <= 3:
			range = 20
		elif stage <= 5:
			n_houses = 7
			range = 30
		elif stage <= 8:
			n_houses = 9
			range = 40

We'll have to define that get_tool() function, as well as some others:

	
func get_median_price():
	var prices:Array[float] = []
	for house in list_houses:
		prices.append(house.price)
	return MathUtils.median(prices)

func get_tool(tool):
	print("get_tool",tool)
	toolbox.add_tool(tool, self.on_tool_pressed)

func on_tool_pressed(tool):
	if tool == "sort":
		do_tool_sort()
	if tool == "median":
		n_guess.value = get_median_price()

func do_tool_sort():
	list_houses.sort_custom(func(a,b):
		return a.price < b.price
	)
	for house in list_houses:
		houses.remove_child(house)
	for house in list_houses:
		houses.add_child(house)

I add a function for calculating the median price, because the median tool needs that. I add a function get_tool() that takes a string and adds that button to the toolbox; we'll define this in a second, but note that I'm passing both the current tool, as well as a local callback.

Handling the median tool is a simple one liner, but sorting the houses requires its own function. In either case I just directly implement the final result, without any animation or other fanciness; we'll save that for the next iteration.

Next, I need to add logic to the toolbox to make it accept new tools:

extends Control

@onready var container = $container
@onready var ToolScene : PackedScene = preload("res://scenes/tool_button.tscn")
var tools:Dictionary = {}

func _ready():
	pass

func add_tool(value, callback):
	if value not in tools:
		tools[value] = true
		var tool:ToolButton = ToolScene.instantiate()
		tool.set_tool(value)
		container.add_child(tool)
		tool.connect("pressed", callback.bind(tool.tool))

I create two variables for container , which is the VBoxContainer that the buttons will sit in, and I also create a variable ToolScene, which is a preload of the tool_button scene, so I can instantiate it whenever I want. I also create a Dictionary (what would be known as a "Map" or "HashMap" in other languages) called tools which help me keep track of what items I have unlocked.

The add_tool() function takes two parameters: value, which is just the name of the tool, and callback, which is a function that we're going to stick on the buttons so that they do something when you click them. We create the tool button by invoking instantiate() on the ToolScene we preloaded, and we call set_tool to update its value and icon. Then, we add it to the container as a child, and then we call connect to attach a function to it. Whenever this button is pressed, it will call the function we passed to add_tool() as the second parameter. Note that we passed this not as callback, but as callback.bind(tool.tool). When you pass a callback with .bind(some_variable), you are making sure that this particular value will be passed back as the parameter when this function is triggered.

In this case if we passed, say, "sort", this function would create a button, give it the sort icon, add it to the scene, and then make it so whenever you press it the parent function activates the on_tool_pressed() button with "sort" as the parameter.

I should note at this point that I'm actually violating a bit of Godot's best practices, because at this point in the iteration I haven't yet learned how to do things properly. Whenever possible, you're supposed to "call down, signal up," which is to say, parent objects should directly call functions on their children, and children should communicate upwards principally by emitting signals, which their parents are subscribed to. What I'm doing here works just fine but is muddying those waters a wee bit. You guessed it–Rule 4–and I'll fix it in a future iteration.

Anyways, we now have two tools, they unlock at specific points in the game, and they each do something simple that is useful to the player.

It's certainly convenient, but it's still not very exciting yet:

Whee.

Juice

Finally it is time. Time for juice.

By Agricultural Research Service Public Domain

For those of you new to the blog, juice is basically the stuff you add that makes the game way more fun and interesting even if it doesn't actually change any mechanics directly. To quote some folks much smarter and more talented than myself:

“Juice” was our wet little term for constant and bountiful user feedback. A juicy game element will bounce and wiggle and squirt and make a little noise when you touch it. A juicy game feels alive and responds to everything you do – tons of cascading action and response for minimal user input. It makes the player feel powerful and in control of the world, and it coaches them through the rules of the game by constantly letting them know on a per-interaction basis how they are doing.

The term is elaborated in these two excellent talks, "The Art of Screenshake", and "Juice it or Lose It":

I previously discussed juice in my own article Oil it or Spoil it, which was directly inspired by the concept. In it I introduced my own metaphorical general-purpose-game-improving liquid, which I dubbed "oil", defined thus:

Whereas Juice is highly visible, Oil is really something you only notice when it's missing. A well-oiled hinge is smooth and silent, but a rusty one squeaks, groans, and annoys the crap out of you. If juice is all about making your game come alive and enriching interactions by maximizing the output you get for a single input, Oil is about minimizing the friction and effort that goes into making an input in the first place.

To put it another way:

Juice adds pleasure, Oil removes pain.

And these are the two main things I'm going to use to implement Rule 5 – improve existing experiences before moving on to adding new ones.

All I have so far is the simplest, stupidest possible game – guess the median. The tools the player eventually gets as progression rewards are oil, essentially. It's an acknowledgment by the game that they've figured out the basic concept we were trying to get across, and now they can use those to speed the tedium up a bit and get to the next most interesting thing.

But juice is just there to make everything more awesome. So let's add a bit. First, the sort is way too boring. Let's make it animate smoothly.

First of all, I put together some nicer sound effects, digging up some public domain and royalty free sound effects, as well as recording a few with my own microphone. I created an organizing group called "sounds" and stuck them all directly in the main scene. It's starting to get unwieldy enough that I am itching for a proper sound manager class, but–Rule 4–I'm not there yet, so I just keep dumping stuff here:

I make sure to keep a running list of licenses that require attribution in a licenses.txt file in my repository, so I don't forget later:

CC attribution 3.0: Woosh-Mark_DiAngelo-4778593.wav, https://soundbible.com/2068-Woosh.html
CC Attribution 4.0: 257227__javierzumer__ui-interface-positive.wav, https://freesound.org/people/JavierZumer/sounds/257227/
CC 0: 590034__mrfossy__sfx_massivelyincorrekt_03.wav, https://freesound.org/people/MrFossy/sounds/590034/

I add all the new sounds as variables in main.gd. Again, starting to get a little messy, but not messy enough yet to push me to refactor:

@onready var sfx_choh = $sounds/choh
@onready var sfx_good = $sounds/good
@onready var sfx_bad = $sounds/bad
@onready var sfx_woosh = $sounds/woosh
@onready var sfx_rattle = $sounds/rattle
@onready var sfx_pop = $sounds/pop
@onready var sfx_shoomp = $sounds/shoomp

Next change. These lines were causing me trouble:

n_guess.value = 0
n_guess.get_line_edit().clear()

SpinBoxes with range constraints act funny when you try to set to out-or-range values (like 0) and then clear the label, so I just replace it with this:

n_guess.value = prices.min()

That way, the initial guess on a new round is just the bottom of the range.

Next, I update the do_tool_sort() function, there's quite a lot going on here:

func do_tool_sort():
	# Generate a map that remembers where each house was and where it is going
	var move_map:Dictionary[int, Dictionary] = {}
	var old_positions = []
	var i = 0
	for house in list_houses:
		var pos = to_local(house.global_position)
		move_map[house.get_instance_id()] = {"start":pos, "end":pos}
		old_positions.append(pos)
		i += 1
	
	# Sort the houses
	list_houses.sort_custom(func(a,b):
		return a.price < b.price
	)
	
	# Observe the final position of each
	i = 0
	var all_same = true
	for house in list_houses:
		var entry = move_map[house.get_instance_id()]
		entry["end"] = old_positions[i]
		if entry["start"] != entry["end"]:
			all_same = false
		i += 1
	
	# No need to animate
	if all_same:
		sfx_rattle.play()
		for house in list_houses:
			Effects.shake(house)
	else:
		sfx_woosh.play()
		# Now, remove all the houses from view
		for house in list_houses:
			houses.remove_child(house)
			add_child(house)
			var entry = move_map[house.get_instance_id()]
			house.set_position(entry["start"])
			Effects.move_node(house, entry["end"], 0.25, func():
				remove_child(house)
				houses.add_child(house)
			)

This does a whole bunch of stuff.

We do the sort logic ahead of time and note where each house is now, and where it will be after the sort.

# Generate a map that remembers where each house was and where it is going
	var move_map:Dictionary[int, Dictionary] = {}
	var old_positions = []
	var i = 0
	for house in list_houses:
		var pos = to_local(house.global_position)
		move_map[house.get_instance_id()] = {"start":pos, "end":pos}
		old_positions.append(pos)
		i += 1
	
	# Sort the houses
	list_houses.sort_custom(func(a,b):
		return a.price < b.price
	)
	
	# Observe the final position of each
	i = 0
	var all_same = true
	for house in list_houses:
		var entry = move_map[house.get_instance_id()]
		entry["end"] = old_positions[i]
		if entry["start"] != entry["end"]:
			all_same = false
		i += 1

A couple noteworthy things here. to_local(house.global_position) is a nice way to cleanly sort out coordinate positioning of any object no matter the context. I'm still new to this, but to_local() seems like it always gives you the local coordinate position of a global coordinate, and .global_position always gives you any object's position in global screen space. So you should always be able to use that pattern to get the answer to: "I want to rip this object out of its current context and put it at the same apparent visual position, but inside this other container. What is the correct position?"

Next, get_instance_id() seems to return a globally unique identifier per instance. This is really useful if you want to do any kind of bookkeeping like we're doing here, keeping track of start and end positions before and after the sort.

Next, we do the animation:

	if all_same:
		# No need to animate
  		sfx_rattle.play()
		for house in list_houses:
			Effects.shake(house)
	else:
		sfx_woosh.play()
		# Now, remove all the houses from view
		for house in list_houses:
			houses.remove_child(house)
			add_child(house)
			var entry = move_map[house.get_instance_id()]
			house.set_position(entry["start"])
			Effects.move_node(house, entry["end"], 0.25, func():
				remove_child(house)
				houses.add_child(house)
			)

In the case that the sort positions haven't changed, we play a rattling sound and make each house shake in place (to be defined later).

In the case that the sort positions have changed, we play a woosh sound, and do a special animation to cover the transition. First, we remove all the houses from view by using remove_child on the houses container to remove each house. We don't delete the actual house object in question though, we use add_child to add it to the main scene itself, outside its container. We use our dictionary to get its start and end positions, then call house.set_position to set its starting position to the proper location. Then, we call a move_node effect that we'll define in a section, and pass it four things:

  • the house object itself,
  • the desired end point position,
  • the amount of time the animation should take,
  • a callback to run when its finished (which removes the house from the main scene tree and adds it back to the houses container)

Now we have to actually define those effects. This can be done purely in code.

We create a new script called effects.gd and give it the class_name Effect.

class_name Effects

static func move_node(
	node: CanvasItem, 
	target: Vector2, 
	time_sec: float, 
	callback: Callable, 
	trans:Tween.TransitionType = Tween.TRANS_CUBIC,
	ease:Tween.EaseType = Tween.EASE_OUT
) -> void:
	var tween = node.create_tween()
	tween.tween_property(
		node,
		"position",
		target,
		time_sec
	).set_trans(trans).set_ease(ease)
	if callback.is_valid():
		tween.finished.connect(callback)

This takes the same four parameters we mentioned before, but there's two optional ones as well that control the transition speed and easing, which I've set to default values for now.

This creates a tween object, which is basically a programmatic animation. Flixel users will find them very familiar. You create the tween, then call tween_property on it, giving it the thing you want to tween, the property you want to change over time, the new value you want it to change to, and how long it should take to get there. We then chain set_trans() and set_ease() onto the returned value to set those values. This sets up all the tween motion logic, which will presumably begin in the next update frame.

Next, having created the tween, we connect our callback to the tween's finished signal. This way, when the tween finishes, whatever logic we want to happen will get invoked.

Combined with our previous code, this means that whenever we sort, a nice swishing sound will play and the houses will nicely animate towards their final positions.

Nice, that's better:

Whee!

Next, if the houses are already sorted, I want a little feedback to indicate that too, rather than have nothing happen. I want them to gently shake back and forth and make a rattling sound.

Here's the code for Effects.shake, ChatGPT helped me put this one together:

static func shake(
	node: Node, 
	magnitude_deg: float = 5.0,
	duration_sec: float = 0.35, 
	wobble_count: int = 3
) -> void:
	
	_center_pivot(node)
	
	# start a one-shot tween that cleans itself up automatically
	var t := node.create_tween()
	t.set_parallel(false)         

	# time per half-wiggle
	var step := duration_sec / (wobble_count * 2.0)     
    
	# current amplitude
	var amp  := magnitude_deg                           

	for _i in wobble_count:
		# tilt right
		t.tween_property(node, "rotation_degrees",  amp, step) \
		 .set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
		# tilt left
		t.tween_property(node, "rotation_degrees", -amp, step) \
		 .set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)

		# dampen each pass so it “settles”
        amp *= 0.6              

	# final snap back to perfect upright
	t.tween_property(node, "rotation_degrees", 0.0, step) \
	 .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)

_center_pivot() was something I added after getting really frustrated, because the house_price objects weren't rotating around the right position, and nothing I could do in the editor seemed to make it right. Finally I brute forced it with this little helper function which seemed to do the trick:

static func _center_pivot(ci: Node) -> void:
	if ci is Control:
		var c := ci as Control
		# after anchors/layout have run, size is final
		c.pivot_offset = c.size * 0.5                       # (w/2, h/2)

	elif ci is Sprite2D:                                    # or AnimatedSprite2D
		(ci as Sprite2D).centered = true                    # Sprite handles it

	elif ci is Node2D:
		var n := ci as Node2D
		# Best effort: if it exposes a bounding rect, use that
		if n.has_method("get_rect"):
			var r : Rect2 = n.call("get_rect")
			n.set_pivot_offset(r.size * 0.5)
		else:
			# Fallback: scan children that are CanvasItems
			var aabb := Rect2()
			for kid in n.get_children():
				if kid is CanvasItem:
					aabb = aabb.expand((kid as CanvasItem).global_position)
			n.set_pivot_offset(aabb.size * 0.5)

The median effect is a bit more complicated, and it's also a little tricky to describe, so maybe I better show you. I want all the individual numbers to fly together like they're combining, then the median should pop out of them and fly towards the SpinBox, accompanied with appropriate sound effects. Here's what it looks like:

There's a few things I need to do to pull off this effect:

  1. Make the price fields on each individual house_price temporarily disappear.
  2. Hide the value in the SpinBox
  3. Duplicate those same fields in their current positions, but in the main scene.
  4. Animate the fields to smoosh together
  5. Throw them away and keep only one, set to the median value
  6. Start a new tween that makes that median value field fly to the SpinBox
  7. Update the value in the SpinBox
  8. Restore the house_price objects to their normal state (showing their prices)

Here's the Effects code I came up with:

static func merge_and_send_object(
	host: CanvasItem,
	inputs: Array[CanvasItem],
	output: CanvasItem,
	target: Vector2,
	callback_merge: Callable,
	callback: Callable
) -> void:
	
	output.visible = false
	
	# Get the mean position of all the inputs
	var mean_pos = MathUtils.pos_call(host, inputs, MathUtils.mean)
	var i = 0
	for input in inputs:
		# Make all the nodes smash together
		if i == 0:
			# Chain the next bit of logic to the first tween
			move_node(input, mean_pos, 0.25, func():
				# Show the output object, and put it at the mean position
				if callback_merge.is_valid():
					callback_merge.call()
				host.add_child(output)
				output.set_position(mean_pos)
				output.visible = true
				var midpoint_y = output.position.y - (target.y - output.position.y)/2
				move_node(output, target, 0.25, callback)
			, Tween.TRANS_CUBIC, Tween.EASE_IN)
		else:
			move_node(input, mean_pos, 0.25, Callable(), Tween.TRANS_CUBIC, Tween.EASE_IN)
		i += 1
	pass

host is the main "stage" the animation will play out on–in my case, the main scene–inputs are the individual objects to be smooshed, output is the object that represents what they combine into, target is the position to send output to, callback_merge gets called when they first smoosh together, and callback gets called when the whole thing is finished.

Note this invokes MathUtils.pos_call and MathUtils.mean, which are also new. Those go in math_utils.gd:


# If you have a list of nodes, allows you do math on their respective positions
static func pos_call(host: CanvasItem, arr: Array[CanvasItem], lambda:Callable) -> Vector2:
	var x:Array[float] = []
	var y:Array[float] = []
	for n in arr:
		var pos = host.to_local(n.global_position)
		x.append(pos.x)
		y.append(pos.y)
	return Vector2(lambda.call(x), lambda.call(y))

static func mean(arr: Array[float]) -> float:
	if arr.is_empty():
		push_error("mean() called with an empty array")
		return NAN
	var sum = 0.0
	for n in arr:
		sum += n
	return sum / len(arr)

So mean() just calculates the mean value, and pos_call is just a helper that lets you do math on the position of a bunch of objects.

Okay great, we've got our effects code. Now we need to actually invoke it. We'll do that in a new do_tool_median() function, which replaces the old one liner:

func do_tool_median():
	var median_price = get_median_price()
	
	var labels:Array[CanvasItem] = []
	for house in list_houses:
		house.show_label("price", false)
		var label = house.label_price.duplicate()
		label.modulate.a = 1.0
		label.visible = true
		labels.append(label)
		add_child(label)
		label.set_position(to_local(house.label_price.global_position))
	
	var median_label:Label = labels[0].duplicate()
	median_label.text = str(int(median_price))
	median_label.visible = false
	
	var target_pos = n_guess.position
	
	# Play shoomp sound effect
	sfx_shoomp.play()
	n_guess.get_line_edit().clear()
	
	Effects.merge_and_send_object(
		self, 
		labels, 
		median_label, 
		target_pos, 
		func():
			# Hide individual labels after we're done with them
			for label in labels:
				remove_child(label)
				label.queue_free()
			sfx_pop.play(),
		func():
			sfx_choh.play()
			n_guess.value = get_median_price()
			median_label.visible = false
			for house in list_houses:
				house.show_label("price", true)
			median_label.queue_free()
	)

Let's break it down:

func do_tool_median():
	var median_price = get_median_price()
	
	var labels:Array[CanvasItem] = []
	for house in list_houses:
		house.show_label("price", false)
		var label = house.label_price.duplicate()
		label.modulate.a = 1.0
		label.visible = true
		labels.append(label)
		add_child(label)
		label.set_position(to_local(house.label_price.global_position))

So first we just get the median price and hang onto that. Then, we create an array to hold all the labels we're about to animate. We iterate through each of our house_price objects, and for each one:

  • We hide the price label (that's a function we'll have to define btw)
  • We directly duplicate the price label object
  • We make sure it's visible. Quick note on that:
    • .modulate.a sets transparency. The a is for "alpha channel." An object that is 100% transparent still takes up as much physical space in a container as if it was fully visible.
    • .visible toggles visibility. Apparently this also affects how much space it takes up in a container like a VBoxContainer or HBoxContainer – so if one of those has three objects, and you set one to .visible = false, there won't be an invisible gap where the invisible one is, the other two will snap together as if the invisible one was never there at all. I'm not sure if this is the "right" way to do things, when I need something to not be drawn, but still "take up space," setting .modulate.a = 0 seems to work.
  • We add the label both to our main scene and to our internal bookkeeping list.
  • We set the label's position to match the same apparent visual position as the label inside the house_price object, so that they perfectly match, even though they're in different parts of the overall scene graph.

Okay, next part:

	var median_label:Label = labels[0].duplicate()
	median_label.text = str(int(median_price))
	median_label.visible = false
	
	var target_pos = n_guess.position
	
	# Play shoomp sound effect
	sfx_shoomp.play()
	n_guess.get_line_edit().clear()

We create the "median label," which is the one that pops out when they all smoosh together. We create this by just duplicating the first of our stored labels. We set its text to match the calculated median price, and we make it invisible for now.

Next, we figure out our target position we want it to fly towards at the end–namely, the position of n_guess, which is our SpinBox. This is in the same scene hierarchy as the label, so we just grab the position directly.

Finally, we play a sound effect to accompany the merge effect we're about to invoke and clear the SpinBox's input field.

Next, we invoke the whole animation effect:

	Effects.merge_and_send_object(
		self, 
		labels, 
		median_label, 
		target_pos, 
		func():
			# Hide individual labels after we're done with them
			for label in labels:
				remove_child(label)
				label.queue_free()
			sfx_pop.play(),
		func():
			sfx_choh.play()
			n_guess.value = get_median_price()
			median_label.visible = false
			for house in list_houses:
				house.show_label("price", true)
			median_label.queue_free()
	)

self refers to the main scene, which is our "host" for the merge_and_send_object function. The things we want to smoosh together are stored in the labels array, the thing we want to pop out in the end is the median_label we've prepared, we want that to fly toward target_pos, and then we supply two callbacks, one to happen at the midpoint when the numbers smash together, and one to happen at the very end.

Savvy readers will note that these are Godot's version of lambdas–single-purpose functions that you define in place as you pass them.

The first one just gets rid of the smooshed labels–we remove each one from the main scene, and then call .queue_free(), which is Godot's general purpose object destructor. Then we play a popping sound to signal the creation of the median label.

The second lambda plays at the very end, when the number reaches the SpinBox. We play a "choh" sound to mark the number settling into place, we set the SpinBox value to match, and hide the median label. Then we restore all the original house_price object's internal price labels, and .queue_free() the median label to destroy it.

As for the show_label() function we added to house_price, here it is:

func show_label(label_id:String, b:bool=true)->void:
	if label_id == "price":
		if b:
			label_price.modulate.a = 1.0
		else:
			label_price.modulate.a = 0.0

This is admittedly a little speculative. I'm anticipating that I'm going to be adding different fields other than just price soon, so I'm at least asking the user to provide a parameter. I manage to stop myself before I go any further though, so there's nothing it checks for other than "price." In any case, this just sets the transparency of the price field to 0 or 1. This is why we had to set .modulate.a back to 1.0 when we duplicated the labels, by the way, because we called show_label() first and passed it false, which would set that value to 0.0, and a duplicated object inherits all the properties of its progenitor.

And that's about it! Now we've expanded on our super minimalistic "guess the median" mini-game and added the following things to it:

  • Two levels of eight stages each
  • Feedback in the form of nicer sound effects
  • Increasing complexity, adding more houses and wider price ranges
  • Unlockable "tools" that serve both as "oil" and as rewards
  • "Juice" in the form of nice animation and sound effects

..and we're done! At least for now.

Next steps

I could add even more "oil" and "juice" to the super introductory "guess the median" mini-game, but I think this is enough for now to move on to the next stage of complexity. The natural next step is to make the buildings be different sizes.

I haven't coded this up yet, but I've designed a quick mockup of what I think should come next. In the next phase, you'll be given a new interface where instead of just having a couple houses and being asked to guess the median of this abstract number, you instead have something with more structure. You have actual comparable sales on the right and an actual subject property on the left.

Then, there's multiple fields (rows, really) per property. I imagine we'll start with the full prices and the size of each property, and your job is to select an appropriate $ / sqft rate for your subject (it will still work out to the median of all the other properties). Later, I will hide the $ / sqft values, and you will need to use a new tool to calculate them. So you will first calculate $ / sqft values, then find the median going rate, and use that to value your subject property. Or something like that, whatever feels interesting and right.

Anyways, that's what I'll work on next time.

As for the game, Godot has a nice HTML5 web export feature which seems to work pretty well. I went ahead and hosted the game on itch.io, so you can play it right now if you want.

Itch apparently requires disclosure for any AI-generated content, so I filled that out–the graphics and sound and textual content are kosher (and I intend to keep it that way), but I did get a little help from ChatGPT in writing a function or two, so I checked this box:

Anyways, you can play the game here on Itch:

Super Municipal Property Tax Simulator 2000 by larsiusprime
Experimental thingy

If you want to follow along with development, subscribe to this blog! Maybe I'll do another post about it! Or maybe not! I have nothing to prove!