Anatomy of a Grand Strategy Game in Godot: Grand Strategy Devlog #1

Charlie Prince
8 min readMay 27, 2024

--

I’ve always been a big fan of Grand Strategy or “spreadsheet games” as my partner calls them. I’ve spent an unhealthy amount of time and money on games like Victoria, Crusader Kings, and Stellaris and have often dreamt of making one of my own. As a web developer by trade, and with my partner being a user experience designer, this type of project seems like a great entry point into game development for us. These games are more UI-oriented, so there’s less need to worry about art and 3D pathfinding.

I’ve dabbled with basic game development before in GameMaker Studio and Unity, but my efforts were cut short by Unity’s recent controversies. I decided to try my hand at Godot. It seems well-liked, easy enough to use, and above all else, it’s completely free.

Starting the Journey

So, how do we start? Like most projects, it begins with Google; we needed to understand how Grand Strategy maps are implemented. Happily, a search for ‘Godot Grand Strategy’ quickly returned results from Good Soltion Interactive, who appeared to be on a similar journey. Their videos provided a great starting point for implementing our map generation system, but we aimed to take it a step further to minimize manual input.

The basic requirement is a map where each region is filled with a unique color. Later, we’ll need to map each color to a region to generate a game level.

Creating the Map

Similarly to Good Solution Interactive, we wanted to start with a level editor to configure our maps and save data so that we don’t have to do everything in a text editor. This will serve as a jumping-off point for any grand strategy game we might want to make down the road. Essentially, we’re building a grand strategy engine that we can later turn into a game.

To start, we needed a world map that is subdivided — every color must be unique. We used mapchart.net to export an SVG and then used JavaScript to give each path a random fill color:

Original exported map
function getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}

function setUniqueColorsToSvgPaths() {
const svgElement = document.querySelector('.mysvg');
const paths = svgElement.querySelectorAll('path');
paths.forEach(path => {
const randomColor = getRandomColor();
path.setAttribute('fill', randomColor);
});
}

setUniqueColorsToSvgPaths();
Colour coded map

This resulted in a uniquely color-coded map.

Processing the Map

Next, we converted the new map to a PNG so we could iterate over each pixel and detect unique colors. Feathering can be an issue, and we didn’t want to manually paint each country to ensure there wasn’t any. Instead, we applied an algorithm that checks neighboring pixels for matching colors.

Each test/pass (top to bottom)

After multiple rounds of testing, we found that four matching neighbors was the correct number to render countries correctly without artifacts. The image above shows each test we ran and the feathered colors picked up each time. We also check for any pixels that have transparency (i.e., alpha channel is not 1) to remove any semi-transparent edges. We may also use semi-transparent colors in the future for additional map data. The final script to create a dictionary of the state colors looks like this:

func get_pixel_color_dict(image):
var pixel_color_dict = {}
var width = image.get_width()
var height = image.get_height()

for y in range(height):
for x in range(width):
var currentPixel = image.get_pixel(x, y)
if currentPixel.a == 1: # Ensure the pixel is fully opaque
var same_color_neighbors = 0

# Define the relative positions of the 8 neighbors
var neighbors = [
Vector2(1, 0), # Right
Vector2(-1, 0), # Left
Vector2(0, 1), # Down
Vector2(0, -1), # Up
Vector2(1, 1), # Down-right
Vector2(-1, 1), # Down-left
Vector2(1, -1), # Up-right
Vector2(-1, -1) # Up-left
]

for offset in neighbors:
var nx = x + offset.x
var ny = y + offset.y
if nx >= 0 and nx < width and ny >= 0 and ny < height:
var neighborPixel = image.get_pixel(nx, ny)
if neighborPixel == currentPixel:
same_color_neighbors += 1

if same_color_neighbors >= 4:
var pixel_color = "#" + currentPixel.to_html(false).to_upper()
if pixel_color not in pixel_color_dict:
pixel_color_dict[pixel_color] = []
pixel_color_dict[pixel_color].append(Vector2(x, y))

return pixel_color_dict

This then returns a dictionary of colors when loading the map:

["#8BED6E", "#FC9AB7", "#6016CC", "#662BC7", "#54DE71", "#E4C5A9", "#67BE34", "#746E71", "#BB16FF", "#E621AF", "#A7F36C", "#83F811", "#B34892", "#B3DEEB", "#B9C516", "#DFACF4", "#1B062E", "#2EA53D", "#3E489F", "#771FFF", "#E3B3D0", "#A8B14B", "#CE34E8", "#0386E4", "#0DA006", "#F71719", "#0AF264", "#8EAB7D", "#B33ECE", "#181629", "#672D39", "#EF72AF", "#0339DF", "#8A8757", "#9BBF38", "#1A5CD1", "#ABB83E", "#B7DC53", "#66D361", "#4FA27D", "#CB205C", "#ACB062", "#710133", "#70F990", "#4A440C", "#E61DDF", "#521337", "#60D02F", "#7B20BA", "#B34A92", "#C9D5E9", "#D27402", "#20B310", "#EB4112", "#12606E", "#9B3422", "#1CE718", "#F6F11E", "#282F21", "#7B4B38", "#06C238", "#255571", "#8861EE", "#F4E2CA", "#8691F8", "#B13EEC", "#B80A6C", "#003CD6", "#266398", "#6E6840", "#ABD445", "#6C092A", "#908961", "#6B5801", "#46CCAC", "#1E0421", "#D8E40D", "#0C1682", "#E09CBB", "#A78A69", "#BFB67C", "#B2911D", "#03CF91", "#93F9EA", "#E6BDAB", "#59C6B7", "#F18763", "#C92395", "#8929BA", "#8279DE", "#BB0D0F", "#088848", "#1E2EE9", "#2A8E78", "#B1BFEB", "#DB6F33", "#227C81", "#F6B1D1", "#4756CF", "#95D1C5", "#E92AC5", "#24F25A", "#D4567D", "#3A4227", "#068C5E", "#359364", "#2AB1E6", "#E40FFB", "#40CF85", "#78F085", "#3A3980", "#654CA9", "#95C9A4", "#0ACDF3", "#5F0008", "#34C2FD", "#C1CBBB", "#24A374", "#C5494A", "#4498FC", "#D07FE0", "#9E0CAE", "#9D0243", "#673C7B", "#0DA170", "#90F0D7", "#4F879D", "#FCAA5D", "#45F058", "#AC82FD", "#F019BD", "#7E0DC4", "#F14C82", "#7D5571", "#04C4D8", "#F61422", "#16E814", "#41A42E", "#CDE9FF", "#212AB7", "#471FA0", "#CB6D26", "#3F6441", "#FE6626", "#D6981D", "#1B6E71", "#78018F", "#9C2122", "#82602B", "#07369A", "#49BFBE", "#B128A1", "#20D428", "#9BB737", "#CC7D96", "#32A9E2", "#09553C", "#E24D4A", "#BEE138", "#CA6E7E", "#C07F88", "#A86E8F", "#74D59D", "#1CE212", "#223471", "#D15E83", "#E2825D", "#A2C1F3", "#0ACA1D", "#6F1CD5", "#BA5EF7", "#EB028B", "#DB9651", "#22C0F4", "#E506FE", "#786CED", "#FF001F", "#FBE869", "#53A2CD", "#B5A752", "#597A1C", "#0643BF", "#64519C", "#B07B6E", "#9D20EF", "#DBDD15", "#54FE6D", "#6845F8", "#E75045", "#3D5BC1", "#9B3551", "#5F143E", "#FCE814", "#E96EB4", "#4AD958", "#784BAF", "#31FF45", "#553C72", "#1571BF", "#9BBCD4"]

Defining the Game Elements

Next, we needed to define our countries, states, and regions. Our regions are configured like this, with the color used as a key. They are mapped to an “owner,” which in this case is the country:

{
"#E2825D": {
"name": "Brittany 01",
"state": "Brittany",
"owner": "#d6655f",
"population": 0
},
"#BA5EF7": {
"name": "Brittany 02",
"state": "Brittany",
"owner": "#d6655f",
"population": 0
}, ...

Countries are configured also with unique colours as keys, we can reference any country data from these keys:

{
"#FFFFFF" : {
"player_name" : "Not set",
"country_name" : "Not set"
},
"#00FFFF" : {
"country_name" : "Scotland"
},
"#FF00FF" : {
"country_name" : "Wales"
},

States are defined in the same way and will allow for us to transfer regions between states:

{
"#50CA6B": {
"Name": "Iceland"
},
"#1A33F7": {
"Name": "Highlands"
},
"#70A45A": {
"Name": "Munster"
},
"#BEDEFF": {
"Name": "Home Counties"
},
"#653A88": {
"Name": "East Anglia"
},

Then we load our regions by iterating over each color in the newly created dictionary, cross-referencing against our region, state, and country dictionaries, and then generating the map based on the data.

func load_regions(
regions_file = "res://_Map_data/isles_regions.txt",
countries_file = "res://_Map_data/county_data.txt",
states_file = "res://_Map_data/states_dict.txt"
):
var image = mapImage.get_texture().get_image()
var pixel_color_dict = get_pixel_color_dict(image)
#print("Pixel Color Dict: ", pixel_color_dict.keys())

var regions_dict = Filesystem.import_file(regions_file)
var countries_dict = Filesystem.import_file(countries_file)
var states_dict = Filesystem.import_file(states_file)

# Assign the countries to Globals.players
Globals.players = countries_dict
Globals.states = states_dict

# Create regions based on pixel colors
for region_color in pixel_color_dict.keys():
var region = load("res://Scenes/Editor_Region_Area.tscn").instantiate()

if region_color in regions_dict:
region.region_data = regions_dict[region_color]
region.region_data["color"] = region_color
#print(region.region_data)
else:
region.region_data = {
"name": "Unnamed",
"population": 0,
"owner": "Unknown"
}

region.set_name(region.region_data["name"])
region.add_to_group("Persist")
get_node("Regions").add_child(region)

var polygons = get_polygons(image, region_color, pixel_color_dict)

for polygon in polygons:
var region_collision = CollisionPolygon2D.new()
var region_polygon = Polygon2D.new()

region_collision.polygon = polygon
region_polygon.polygon = polygon

# Create the outline using Line2D
var outline = Line2D.new()
outline.width = 2
outline.default_color = Color(0, 0, 0, 1)

# Add points to the outline
for i in range(polygon.size()):
outline.add_point(polygon[i])

# Close the loop
if polygon.size() > 0:
outline.add_point(polygon[0])

region.add_child(outline)

region.add_child(region_collision)
region.add_child(region_polygon)

The original map is hidden in the scene with our new interactive map taking its place. The default view is coded by country, but we can add new views such as state.

Interactive map

Interactive Map Editing

We can click on a region to modify its details, including its parent state. This will allow for creating split-states as a result of war or diplomacy.

Editing a state in state view

Finally, we needed to add the ability to save edits made to the map as well as load previously edited maps. This will need further development to allow for new saves to be created. For the level editor, all we need to save is the map data (region, state, country) in the same format we’re loading it. This way, we can regenerate the map based on the save file data, rather than the default data.

Here is the save function:

func _on_save_to_file_button_pressed():
# Example usage
var region_data = get_all_region_data()
#print(region_data)
var all_region_data = []
var regions_parent = get_tree().root.get_node("Main/Regions")
for region in regions_parent.get_children():
if region.has_method("get_region_data"):
all_region_data.append(region.get_region_data())

# Ensure the directory exists
var directory_path = "res://Levels"
var dir = DirAccess.open(directory_path)
dir.make_dir("PlayerLevel")

# Convert data to JSON strings
var region_data_json = JSON.stringify(region_data)
var country_data_json = JSON.stringify(Globals.players)

# Save region data to file
var region_file = FileAccess.open("res://Levels/PlayerLevel/Region_Data.txt", FileAccess.WRITE)
if region_file:
region_file.store_string(region_data_json)
region_file.close()
else:
print("Failed to open Region_Data.txt for writing")

# Save country data to file
var country_file = FileAccess.open("res://Levels/PlayerLevel/Country_Data.txt", FileAccess.WRITE)
if country_file:
country_file.store_string(country_data_json)
country_file.close()
else:
print("Failed to open Country_Data.txt for writing")

Globals.instantiate_modal("Saved map data")

Then to load a map, we simply need to reinitialize the map with the save file data:

func _on_load_from_file_button_pressed():
var regions_parent = get_tree().root.get_node("Main/Regions")
for child in regions_parent.get_children():
child.queue_free()

#Filesystem.load_game()
get_parent().load_regions(
"res://Levels/PlayerLevel/Region_Data.txt",
"res://Levels/PlayerLevel/Country_Data.txt"
)

get_parent().onready(self)

Conclusion

We’re now able to generate, update, save, and import our maps. Later, we will use the generated map to create the initial simulation where region statistics will change over time based on various factors. This foundational work sets us up for creating a robust grand strategy game engine.

Stay tuned for the next devlog where we’ll dive into adding more complex game mechanics and refining the user interface.

Read on:

Converting a 2D scene to 3D in Godot: Grand Strategy Devlog #2

--

--

Charlie Prince

Web Developer based in the UK with a focus on frontend technology and web accessibility