A Space-Faring Sandbox Development Journey


Astrogarch

When I started Project Astrogarch, I envisioned it as a learning sandbox, a space where I could experiment with systems and mechanics without the pressure of a defined end goal. Taking inspiration from games like Hinterlands, Mass Effect, Idle Factory, and Incremancer, I set out to create a space-faring incremental game that would evolve as my skills expanded.



The Foundations: Building a Tick System

The project began with implementing a simple tick system that fires once per second, a foundational element for any incremental game. However, my focus quickly shifted toward creating a navigable space environment.

Galactic Navigation: Efficiency in Generation

The Galactic Map scene was my first major challenge. To make it efficient, I implemented two dictionaries:

  • Spawned Chunks: Tracks currently generated sectors
  • Visible Chunks: Recalculated every frame

This approach allows non-visible chunks to be removed and despawned via queuefree(), significantly optimizing performance. For consistency across gameplay sessions, I implemented seeded RNG to create "stably random" areas that persist in the same state over time, even through despawning and respawning cycles.

From Map to Sectors: Procedural Planet Generation

Clicking a sector on the galactic map transports players into that sector, revealing a star with orbiting planets. The procedural generation of these planets became one of my most rewarding challenges, pushing me to optimize for both visual quality and performance.

Solving the Noise Resolution Problem

Initially, I generated noise on spawn, but could only achieve low-resolution results (32×32 or 48×48) in real-time, far from the visual quality I wanted. This limitation led me to create custom Tools in Godot to pre-generate noise textures, which then needed to be loaded efficiently.

The Loading Screen Milestone

To solve the slow ROM data access issue, I implemented my first-ever loading screen, a small but significant milestone! The system preloads textures into RAM through a global GameManager script:

func load_star_files():
	var noise_folder = DirAccess.open("res://Textures/Galactic Map/Star Noise/")
	var files : PackedStringArray = noise_folder.get_files()
	star_noise_textures_to_load = files.size()
	for i in range(files.size()):
		files[i] = str("res://Textures/Galactic Map/Star Noise/",files[i])
	
	for f in files:
		star_noise_textures_loaded += 1
		if (not f.ends_with(".import")): continue
		f = f.replace(".import", "")
		var img : Texture2D = ResourceLoader.load(f, "CompressedTexture2D")
		star_noise_textures.append(img)
	
	call_deferred("star_loading_done")

func star_loading_done():
	loading_bar.value = 100
	loading_message.text = "Done Loading!"
	sector_background_task_id = WorkerThreadPool.add_task(load_sector_backgrounds)

This loading system demonstrates clever resource management by scanning a directory for noise textures, loading them into memory, and tracking progress. The strength of this approach is that it front-loads the heavy resource loading, making runtime performance much smoother. It also provides visual feedback to the player with a loading bar.

For improvement, this could be refactored into a generic resource loader that handles different asset types through configuration rather than duplicating similar code for different resources. Adding error handling for missing files would also make it more robust.

Beautiful Worlds: The Planet Shader

With high-quality noise files now at my disposal, I created a custom shader for making visually interesting 2D planets that "rotate" and offer extensive configuration options:

shader_type canvas_item;

uniform sampler2D land_noise : filter_nearest, repeat_enable;
uniform sampler2D cloud_noise : filter_nearest, repeat_enable;

uniform vec2 spin = vec2(0.2, 0); 
uniform vec2 sun_dir = vec2(1.0, 0.0);

uniform vec4 land_color : source_color;
uniform vec4 water_color : source_color;
uniform vec4 atomsphere_color : source_color;

uniform float water_level : hint_range(0.0, 1.0, 0.001);
uniform float snow_level : hint_range(0.0, 1.0, 0.001);

void fragment() {
	vec4 mask = COLOR;
	vec4 c = texture(land_noise, vec2(UV.x / 2.0 + TIME * spin.x, UV.y / 2.0 + TIME * spin.y)) * mask.a;
	vec4 cc = texture(cloud_noise, vec2(UV.x + TIME * (-spin.x * 0.25), UV.y + TIME * (-spin.y * 0.25))) * mask.a;
	
	float sun_dist =  clamp(distance(vec2(0.5, 0.5) + normalize(sun_dir) * 0.3, UV), 0.05, 0.49);
	float dist =  clamp(distance(vec2(0.5, 0.5), UV), 0.05, 0.49);
	float ydist = distance(vec2(UV.x, 0.5), UV);
	
	vec2 centeredUV = UV * 2.0 - 1.0;
	vec2 spin_vec = normalize(spin);
	float distAlongSpin = dot(centeredUV, spin_vec);
	float distFromEquator = length(centeredUV - distAlongSpin * spin_vec);
	
	float mask2 = smoothstep(0.3, 0.0, distFromEquator);
	float mask3 = smoothstep(0.5, 1.0, distFromEquator);
	
	COLOR *= c;
	COLOR = (c.r <= water_level) ? 
	(COLOR + 0.6) * mask.a * water_color : 
	(COLOR * (land_color));
	//COLOR = (ydist > 0.95 / c.r * 0.175 && (c.r > water_level)) ? COLOR + vec4(0.75,0.75,0.75,1.0) * c.a : COLOR;
	//COLOR = (ydist < 0.15 / c.r * 0.30 && (c.r > water_level)) ? COLOR + vec4(1.0,0.0,0.0,1.0) * (1.0 - ydist) * c.a : COLOR;
	COLOR = (c.r >= water_level && c.r < water_level + 0.035) ? COLOR + vec4(.55, .55, 0.0, 1.0) : COLOR;
	COLOR.rgb = (c.r > water_level + water_level * 0.33 && c.r < snow_level - snow_level * 0.05 && mask2 < 0.08 * (c.r * 7.0) && mask3 < 0.075 * (c.r * 3.0)) ? COLOR.rgb * 0.35 : COLOR.rgb;
	COLOR.rgb = (c.r >= water_level + 0.075 && ((mask2 < 0.08 * (c.r * 7.0) && c.r >= snow_level) || mask3 > 0.1 * (c.r * 3.0))) ? COLOR.rgb * c.r + vec3(1.75) : COLOR.rgb;
	COLOR.rgb = (c.r > water_level + 0.075 && mask2 > 0.1 * (c.r * 7.0)) ? COLOR.rgb + vec3(0.7, 0.5 , 0.0) : COLOR.rgb;
	
	COLOR += (cc.r > 0.75 + sin(TIME * 0.25) * 0.1) ? vec4(0.7,0.7,0.7,1.0) : vec4(0.0,0.0,0.0,0.0);
	
	vec4 atmo = atomsphere_color * 4.0;
	atmo *= dist * 0.1;
	COLOR.rgb *= (1.1 - dist * 2.2);
	COLOR.rgb += (atmo.rgb);
	COLOR.rgb *= (1.75 - sun_dist * 2.75);
}

This shader is quite impressive in how it simulates a rotating planet using 2D noise textures. It creates the illusion of a spherical world with dynamic features like rotating clouds, atmospheric effects, and geographically varied terrain.

The strength lies in its parameterization - everything from water levels to snow coverage can be configured. The way it handles atmospheric scattering based on the sun direction creates convincing lighting effects. The equatorial and polar region handling adds natural-looking variety to the planet's appearance.

A potential improvement might be to refactor some of the more complex conditional logic into functions for better readability. The shader could also benefit from performance optimizations for lower-end devices, perhaps with a quality setting parameter.

Planet Generation in Action

Here's how I use the shader to create unique planets:

class_name Planet
extends Node2D

@export var gfx : Sprite2D
@export var planet_arc : PlanetArc

var planet_seed : int = 0
var sun_pos : Vector2
var planet_mat : Material

var dist_from_star : float
var angle_from_star : float
var orbit_direction : float
var orbit_speed : float

func _process(_delta: float) -> void:
	var elapsed_time_sec = Time.get_ticks_msec() / 1000.0
	var new_angle_from_star = fmod(elapsed_time_sec * orbit_speed * orbit_direction + angle_from_star, 360)
	global_position = sun_pos + Vector2.from_angle(deg_to_rad(new_angle_from_star)).normalized() * dist_from_star
	
	if (planet_mat != null):
		planet_mat.set("shader_parameter/sun_dir", sun_pos - global_position)
		
	planet_arc.queue_redraw()

func generate_planet(_planet_seed : int, _sun_pos : Vector2, _sun_color: Color, _distance : float, _angle):
	planet_seed = _planet_seed
	sun_pos = _sun_pos
	dist_from_star = _distance
	angle_from_star = _angle
	Game_Manager.rng.seed = planet_seed
	planet_mat = gfx.material
	planet_mat.set("shader_parameter/land_noise", 
		Game_Manager.planet_noise_textures[
			Game_Manager.rng.randi_range(0, Game_Manager.planet_noise_textures.size()-1)
		]
	)
	planet_mat.set("shader_parameter/cloud_noise", 
		Game_Manager.planet_noise_textures[
			Game_Manager.rng.randi_range(0, Game_Manager.planet_noise_textures.size()-1)
		]
	)
	
	planet_mat.set("shader_parameter/spin", 
		Vector2(
			Game_Manager.rng.randf_range(0.15, 0.05), 
			Game_Manager.rng.randf_range(0.01, 0.075) * (1 if Game_Manager.rng.randi_range(1, 2) == 1 else -1)
		) / 2
	)
	planet_mat.set("shader_parameter/sun_dir", sun_pos - global_position)
	planet_mat.set("shader_parameter/land_color", Color.from_hsv(
		Game_Manager.rng.randf_range(0.055, 0.389),
		1.0, 1.0, 1.0
	))
	planet_mat.set("shader_parameter/water_color", Color.from_hsv(
		Game_Manager.rng.randf_range(0.555, 0.778),
		1.0, 1.0, 1.0
	))
	planet_mat.set("shader_parameter/atomsphere_color", _sun_color)
	planet_mat.set("shader_parameter/water_level", Game_Manager.rng.randf_range(0.4, 0.7))
	planet_mat.set("shader_parameter/snow_level", Game_Manager.rng.randf_range(0.8, 0.85))
	gfx.scale *= Game_Manager.rng.randf_range(0.66, 1.33)
	
	planet_arc.set_params(_sun_pos, _distance)
	orbit_direction = -1 if Game_Manager.rng.randf_range(0, 100) < 50 else 1
	orbit_speed = Game_Manager.rng.randf_range(0.5, 1.5)

This Planet class showcases excellent use of seeded randomization to create consistent yet unique celestial bodies. It handles both visual appearance and orbital mechanics, calculating the planet's position based on elapsed time, orbital distance, and speed.

The strength of this approach is how it ties visual elements to gameplay mechanics - each planet has a unique orbit determined by its parameters. The sun direction is dynamically updated each frame to ensure proper lighting effects.

For improvement, the planet generation could be expanded to include different planet types (gas giants, ice planets, etc.) with distinct visual characteristics. The orbital calculations could also account for elliptical orbits rather than perfect circles for more realism.

The Next Frontier: 3D Planets

After taking a short break for coursework, I returned with fresh inspiration: redesigning the system in 3D! The new spatial shader brings planets to life in a three-dimensional space:

shader_type spatial;

uniform sampler2D land_tex;
uniform sampler2D cloud_tex;

uniform vec4 land_color : source_color;
uniform vec4 water_color : source_color;

uniform float water_level : hint_range(-0.5, 0.5, 0.001);
uniform float snow_coverage : hint_range(0.0, 1.0, 0.0001);
uniform float cloud_coverage : hint_range(0.1, 1.0, 0.001);
uniform float continental_effect : hint_range(1.0, 8.0, 0.001);
uniform float continental_drift : hint_range(2.0, 256.0, 2);

uniform float cloud_speed : hint_range(1.0, 1024.0, 0.01);
uniform float water_speed : hint_range(1.0, 1024.0, 0.01);

uniform vec2 rotation_vector = vec2(1.0, 0.0);

void fragment() {
	vec4 tex_color = texture(land_tex, UV);
	vec4 water_offset = texture(land_tex, vec2(UV.x + TIME * rotation_vector.x / water_speed, UV.y + TIME * rotation_vector.y / water_speed));
	vec4 water_offset2 = texture(land_tex, vec2(UV.x - TIME * rotation_vector.y / water_speed / 2.0, UV.y - TIME * rotation_vector.x / water_speed / 2.0));
	vec4 cloud_color = texture(cloud_tex, vec2(UV.x + TIME * rotation_vector.x / cloud_speed, UV.y + TIME * rotation_vector.y / cloud_speed));
	
	float dist_from_pole = distance(UV, vec2(UV.x, 0.5));
	float polar_cap_cut = 0.3 + tex_color.r / 8.0;
	float continental_mod = ((sin(UV.x * PI * continental_drift) + 1.75) / 2.0) / continental_effect;
	float mod_water_lever = water_level + dist_from_pole * 1.5 + continental_mod;
	
	//Base texture
	ALBEDO = tex_color.rgb;
	
	//Polar Caps
	if (dist_from_pole > polar_cap_cut)
		ALBEDO = mix(tex_color.rgb, vec3(1.0), clamp(dist_from_pole * 100.0, 0.0, 1.0));
		
	// Water
	if (tex_color.r < mod_water_lever && dist_from_pole <= polar_cap_cut)
		ALBEDO = min(water_color.rgb, max(tex_color.r + 0.35, 0.8) 
		* max(water_offset.r + 0.25, 0.65) 
		* max(water_offset2.r + 0.15, 0.55));
	
	//beaches
	else if (tex_color.r < mod_water_lever * 1.10 && dist_from_pole <= polar_cap_cut)
		ALBEDO *= vec3(1.0, 1.0, 0.0);
	
	//snow
	else if (tex_color.r * dist_from_pole >= (1.0 - snow_coverage) && dist_from_pole <= polar_cap_cut)
		ALBEDO = min(vec3(1.0), ALBEDO * (vec3(1.4) * (1.4 - dist_from_pole)));
		
	//land
	else if (tex_color.r >= mod_water_lever && dist_from_pole <= polar_cap_cut)
		ALBEDO *= land_color.rgb;
	
	//Desert Equator
	if (tex_color.r >= mod_water_lever && dist_from_pole < 0.175)
		ALBEDO = mix(vec3(1.0, 0.55, 0.0), ALBEDO, clamp(dist_from_pole * 8.0, 0.3, 1.0));
		
	//clouds
	if (cloud_color.r >= 1.0 - cloud_coverage + ((sin(TIME / 4.0) + 2.0)/2.0) / 8.0)
		ALBEDO = min(vec3(1.0), ALBEDO + cloud_color.rgb * .5);
		
}

This 3D shader represents a significant evolution in the planet rendering system. It introduces more sophisticated geographical features like continental drift, polar caps, beaches, and equatorial desert regions. The cloud and water movement systems create a convincing sense of planetary weather patterns.

The shader's strength is its layered approach to terrain visualization - handling water, land, snow, and clouds as distinct visual elements affected by geographic position. The continental drift parameter adds natural-looking landmass distributions.

To improve this further, the shader could incorporate normal mapping for surface detail and height-based coloration for mountains and valleys. Performance optimizations like level-of-detail could be implemented to maintain frame rates when viewing planets from different distances.

Looking Forward

The next step is rebuilding the galactic map and sectors entirely in 3D! Working on Astrogarch has been refreshingly different from the usual pressure of game jams or generic software development projects. It's given me space to learn and experiment at my own pace.

Stay tuned for more updates as this sandbox project continues to evolve. Who knows where this space adventure will lead next?


This devlog outlines my progress on Project Astrogarch, a procedural space-faring game built in Godot. Follow along as I continue developing and expanding this learning sandbox!


Author: Isaac Hisey
AKA: TheTornadoTitan
Created:
April 20, 2025, 5:25 p.m.
Last Updated:
April 20, 2025, 5:30 p.m.