Bob's Crystals A vibrant, music-infused, tile-based action PC game with limitless creativity!

Well now you pointed out the Earth curve I can see it, but had no idea at first. Also I thought the sun was supposed to be a crystal.

I think the font gradient is lovely, although the white outline feels a bit off. Also to finish this off I feel like there should be some hint of patterning to the blue lettering. Here is a crystal randomly pulled of an image search:


Something ‘like’ that, but not too full on.

On the whole though it is clean, but some things just need a bit more polish. Like the top corners of the R and B have different spurs, the same with the S ends are curved differently, and the Y looks lobsided. The spacing is also a bit disjointed, like the space between the R and Y at closest approach is bigger than the Y and the S. Even the T has different horizontal length arms. All these things together (and probably a few more bits like the Y arm join point being higher than where the R central point is) give it all a slight ‘weird’ feel.

Anyway, if you left it exactly as it is, I am sure it wouldn’t really make any difference in the long run, but you did ask!

The curvy lines are just right though, not too much and not too little.

PS Just took a look again at your original logo. A great improvement overall IMHO.

1 Like

Thanks so much for taking the time to look this over and share your suggestions, I really appreciate it. I understand what you mean about the infill hinting toward crystals, good point!
I’ll take this on board. :wink:

1 Like

Hey everyone, just wanted to share a milestone:
Bob’s Crystals has been selected for the 2025 Dutch Game Awards. Really excited to see it among so many great projects.

If you’re curious, here’s the official page:

It’s been a lot of work getting it this far, so seeing it shortlisted feels pretty rewarding. Happy to answer any questions or just chat about the process if anyone’s interested.

2 Likes

It’s such a dynamic looking game, with spectacular visuals, I am not surprised at all. Congratulations though, I hope it does really well and even wins! It deserves it!

1 Like

Hi all,

Something want to share with you.. and yes… made in combination with AI bladiebla.. because that is what i do.

But works like a charm. just add this (with your adjustments on your asset storage) to your project (project settings → preload). it will automaticly load all your assets making you gameplay feeling to run a lot smoother than loading on runtime.

So feel free to use bits and pieces of this code for you own “preloading” thingamabob. It looks a lot, but it is actually 5 times the same, but with a different path. So stripping it would be easy.

# res://scripts/asset_preloader.gd
# Global asset_preloader
extends Node

# Singleton for preloading and caching game assets
# Add this as an AutoLoad in Project Settings

# Cache dictionaries for different asset types
var tile_scenes: Dictionary = {}
var effect_scenes: Dictionary = {}
var sound_resources: Dictionary = {}
var music_resources: Dictionary = {}
var skybox_resources: Dictionary = {}
var model_resources: Dictionary = {}

# Preloading state
var is_preloading: bool = false
var preloading_completed: bool = false
var preload_progress: float = 0.0
var total_assets_to_load: int = 0
var assets_loaded: int = 0
var current_loading_asset: String = ""
var failed_assets: Array = []

# Progress threshold for showing "complete"
const SHOW_COMPLETE_AT_PROGRESS: float = 0.95  # Show complete at 95%

signal preload_progress_updated(progress: float)
signal asset_loaded(asset_name: String, asset_type: String)
signal preload_completed()
signal preload_failed(error_message: String)

func _ready():
	# Start preloading immediately when the game starts
	call_deferred("start_preloading")

func start_preloading():
	if is_preloading:
		return
		
	if Global.DEVELOPER_FEATURES:
		_finish_preloading()
		return
		
	is_preloading = true
	assets_loaded = 0
	preload_progress = 0.0
	failed_assets.clear()
	print("Starting asset preloading...")
	
	# Use a timeout to prevent infinite hanging
	var timeout_timer = get_tree().create_timer(30.0)  # 60 second timeout
	timeout_timer.timeout.connect(_on_preload_timeout)
	
	# Calculate total assets to preload
	_calculate_total_assets()
	
	# Preload different asset categories with error handling
	await _preload_tile_scenes()
	await _preload_effect_scenes()
	await _preload_audio_resources()
	await _preload_skybox_resources()
	await _preload_model_resources()
	
	# Mark as completed
	_finish_preloading()

func _finish_preloading():
	is_preloading = false
	preloading_completed = true
	current_loading_asset = ""
	preload_progress = 1.0
	
	if failed_assets.size() > 0:
		print("Asset preloading completed with ", failed_assets.size(), " failed assets:")
		for failed_asset in failed_assets:
			print("  - Failed to load: ", failed_asset)
	else:
		print("Asset preloading completed successfully!")
	
	preload_completed.emit()

func _on_preload_timeout():
	print("Asset preloading timed out!")
	preload_failed.emit("Asset preloading timed out after 60 seconds")
	_finish_preloading()

func _calculate_total_assets():
	# Count all assets that need to be preloaded
	total_assets_to_load = 0
	
	# Count tile scenes from level_elements.json
	if Global.level_element_config.has("levelelements"):
		for category in Global.level_element_config["levelelements"]:
			total_assets_to_load += Global.level_element_config["levelelements"][category].size()
	
	# Count effect scenes
	var effect_paths = _get_effect_paths()
	total_assets_to_load += effect_paths.size()
	
	# Count audio files
	total_assets_to_load += _count_directory_files("res://assets/soundfx/", [".mp3", ".wav", ".ogg"])
	total_assets_to_load += _count_directory_files("res://assets/levelmusic/", [".mp3", ".wav", ".ogg"])
	total_assets_to_load += _count_directory_files("res://assets/levelvoices/", [".mp3", ".wav", ".ogg"])
	
	# Count skybox resources
	total_assets_to_load += _count_directory_files("res://assets/skyboximagesgame/", [".res", ".tres"])
	
	# Count model resources
	var model_paths = _get_model_paths()
	total_assets_to_load += model_paths.size()
	
	print("Total assets to preload: ", total_assets_to_load)
	
	# Ensure we have at least 1 to prevent division by zero
	if total_assets_to_load == 0:
		total_assets_to_load = 1

func _count_directory_files(directory_path: String, extensions: Array) -> int:
	var count = 0
	var dir = DirAccess.open(directory_path)
	if dir:
		dir.list_dir_begin()
		var file_name = dir.get_next()
		while file_name != "":
			if not dir.current_is_dir():
				var file_extension = "." + file_name.get_extension()
				if file_extension in extensions:
					count += 1
			file_name = dir.get_next()
		dir.list_dir_end()
	return count

func _preload_tile_scenes():
	print("Preloading tile scenes...")
	
	if not Global.level_element_config.has("levelelements"):
		print("No level elements config found")
		return
		
	for category_name in Global.level_element_config["levelelements"]:
		var category = Global.level_element_config["levelelements"][category_name]
		
		for block in category:
			var block_name = block.get("name", "")
			var tile_path = block.get("tile", "")
			
			if block_name != "" and tile_path != "":
				current_loading_asset = block_name
				
				if ResourceLoader.exists(tile_path):
					var scene_resource = ResourceLoader.load(tile_path, "PackedScene")
					if scene_resource:
						tile_scenes[block_name] = scene_resource
						asset_loaded.emit(block_name, "tile")
					else:
						print("Failed to load tile scene: ", tile_path)
						failed_assets.append(tile_path)
				else:
					print("Tile scene doesn't exist: ", tile_path)
					failed_assets.append(tile_path)
				
				_update_progress()
				
				# Yield to prevent frame drops
				await get_tree().process_frame

func _get_effect_paths() -> Array:
	return [
		"res://scenes/effect_smokeplosion/effect.tscn",
		"res://scenes/effect_welder/effect.tscn",
		"res://assets/leveleffects/lightning/lightning.tscn",
		"res://assets/leveleffects/explosion/explosion.tscn",
	]

func _preload_effect_scenes():
	print("Preloading effect scenes...")
	
	var effect_paths = _get_effect_paths()
	
	for path in effect_paths:
		var effect_name = path.get_file().get_basename()
		current_loading_asset = effect_name
		
		if ResourceLoader.exists(path):
			var effect_resource = ResourceLoader.load(path, "PackedScene")
			if effect_resource:
				effect_scenes[effect_name] = effect_resource
				asset_loaded.emit(effect_name, "effect")
			else:
				print("Failed to load effect scene: ", path)
				failed_assets.append(path)
		else:
			print("Effect scene doesn't exist: ", path)
			failed_assets.append(path)
		
		_update_progress()
		await get_tree().process_frame

func _preload_audio_resources():
	print("Preloading audio resources...")
	
	# Preload sound effects
	var sound_dir = "res://assets/soundfx/"
	await _preload_directory_resources(sound_dir, sound_resources, [".mp3", ".wav", ".ogg"], "sound")
	
	# Preload music
	var music_dir = "res://assets/levelmusic/"
	await _preload_directory_resources(music_dir, music_resources, [".mp3", ".wav", ".ogg"], "music")
	
	# Preload voice files
	var voice_dir = "res://assets/levelvoices/"
	await _preload_directory_resources(voice_dir, sound_resources, [".mp3", ".wav", ".ogg"], "voice")

func _preload_skybox_resources():
	print("Preloading skybox resources...")
	
	var skybox_dir = "res://assets/skyboximagesgame/"
	await _preload_directory_resources(skybox_dir, skybox_resources, [".res", ".tres"], "skybox")

func _get_model_paths() -> Array:
	return [
		"res://assets/models/bob/bob.tscn",
		"res://assets/models/bobexplode/bobexplode.tscn",
		"res://assets/models/bob_highres/bob_highres.tscn",
	]

func _preload_model_resources():
	print("Preloading model resources...")
	
	var model_paths = _get_model_paths()
	
	for path in model_paths:
		var model_name = path.get_file().get_basename()
		current_loading_asset = model_name
		
		if ResourceLoader.exists(path):
			var model_resource = ResourceLoader.load(path, "PackedScene")
			if model_resource:
				model_resources[model_name] = model_resource
				asset_loaded.emit(model_name, "model")
			else:
				print("Failed to load model: ", path)
				failed_assets.append(path)
		else:
			print("Model doesn't exist: ", path)
			failed_assets.append(path)
		
		_update_progress()
		await get_tree().process_frame

func _preload_directory_resources(directory_path: String, cache_dict: Dictionary, extensions: Array, asset_type: String):
	var dir = DirAccess.open(directory_path)
	if not dir:
		print("Failed to open directory: ", directory_path)
		return
		
	dir.list_dir_begin()
	var file_name = dir.get_next()
	
	while file_name != "":
		if not dir.current_is_dir():
			var file_extension = "." + file_name.get_extension()
			if file_extension in extensions:
				var full_path = directory_path + file_name
				var resource_name = file_name.get_basename()
				current_loading_asset = resource_name
				
				if ResourceLoader.exists(full_path):
					var resource = ResourceLoader.load(full_path)
					if resource:
						cache_dict[resource_name] = resource
						asset_loaded.emit(resource_name, asset_type)
					else:
						print("Failed to load resource: ", full_path)
						failed_assets.append(full_path)
				else:
					print("Resource doesn't exist: ", full_path)
					failed_assets.append(full_path)
				
				_update_progress()
				await get_tree().process_frame
		
		file_name = dir.get_next()
	
	dir.list_dir_end()

func _update_progress():
	assets_loaded += 1
	preload_progress = float(assets_loaded) / float(total_assets_to_load) if total_assets_to_load > 0 else 1.0
	preload_progress = clamp(preload_progress, 0.0, 1.0)
	
	# Signal completion when we hit 95%, even though loading continues
	if preload_progress >= SHOW_COMPLETE_AT_PROGRESS and not preloading_completed:
		preloading_completed = true
		print("Signaling completion at 95% - remaining assets load in background")
		preload_completed.emit()
	
	preload_progress_updated.emit(preload_progress)

# Public methods to get preloaded assets
func get_tile_scene(block_name: String) -> PackedScene:
	return tile_scenes.get(block_name)

func get_effect_scene(effect_name: String) -> PackedScene:
	return effect_scenes.get(effect_name)

func get_sound_resource(sound_name: String) -> AudioStream:
	return sound_resources.get(sound_name)

func get_music_resource(music_name: String) -> AudioStream:
	return music_resources.get(music_name)

func get_skybox_resource(skybox_name: String) -> Resource:
	return skybox_resources.get(skybox_name)

func get_model_resource(model_name: String) -> PackedScene:
	return model_resources.get(model_name)

# Check if specific asset is preloaded
func is_tile_preloaded(block_name: String) -> bool:
	return tile_scenes.has(block_name)

func is_effect_preloaded(effect_name: String) -> bool:
	return effect_scenes.has(effect_name)

# Fallback method - load asset if not preloaded
func get_tile_scene_safe(block_name: String) -> PackedScene:
	if tile_scenes.has(block_name):
		return tile_scenes[block_name]
	
	# Fallback to loading from level_element_config
	var block_data = Global.find_block_data(block_name)
	if block_data.has("tile"):
		var scene_resource = ResourceLoader.load(block_data["tile"], "PackedScene")
		if scene_resource:
			tile_scenes[block_name] = scene_resource  # Cache for next time
			return scene_resource
	
	return null

# Force complete preloading (for debugging)
func force_complete_preloading():
	print("Force completing preloading...")
	_finish_preloading()

# Get current loading status
func get_loading_status() -> Dictionary:
	return {
		"is_preloading": is_preloading,
		"completed": preloading_completed,
		"progress": preload_progress,
		"assets_loaded": assets_loaded,
		"total_assets": total_assets_to_load,
		"current_asset": current_loading_asset,
		"failed_assets": failed_assets.size()
	}

# Get progress as percentage string for UI display
func get_progress_text() -> String:
	if preloading_completed:
		return "Complete!"  # Show "Complete!" once we hit 95%
	else:
		var percentage = int(preload_progress * 100)
		return str(percentage) + "%"

Just wanted to share… Bod’s Crystals is running great on my Steamdeck.. Whohooo…

2 Likes

I have that same laptop and did a double take when I saw it. Game looks really good on the Steam Deck!

1 Like

I’m now sharing Steam keys with anyone interested in joining the Bob’s Crystals “playtest” mission.
Your objectives: infiltrate the game, snap screenshots, log intel, and construct test-levels.
If your builds make the cut, they go canon — part of the official storyline.

Minimum System Requirements (estimated):
– OS: Windows 10 (or Steam Deck)
– CPU: Intel Core i5 or better
– GPU: NVIDIA RTX 3000 series or equivalent
– Must support Vulkan 1.2

All feedback welcome — bugs, balance, flow, pacing… or just pure snark.
Make it constructive, make it brutal — I can take it.
BOB probably can’t, so a little positive now and then is also appreciated :wink:

Want in? Access protocol here:
:backhand_index_pointing_right: Steam key Playtest registration :backhand_index_pointing_left:

Note: limited keys available — when they’re gone, they’re gone.

2 Likes

Congrats on reaching EA :wink:
Best of luck with the continued development and sales.
What made you choose the 20 dollars price tag ?
To me, it’s the price for a top tiers finished Indie game, not for an EA title, but that’s just my opinion :wink:
Cheers !

P.S Stellar art direction, been wanting to say that for a while :slight_smile:

1 Like

Awesome! I’ve registered for a code - looking forward to trying it on my own steam deck :grin:

1 Like

Thanks! :blush:

The price is actually $15, not $20, or did i make a mistake ?!.. let me recheck :wink:

I don’t really separate Early Access and full release when it comes to pricing. The game’s already in good shape and I’ll just keep building on it from here.

Plus, Steam does a lot of events and festivals where you can offer big discounts,even 50% sometimes,so starting at $15 gives me a bit of room to do that and still have something left over.

And really appreciate the kind words about the art direction,that made my day :grinning_face_with_smiling_eyes:

Cheers!

1 Like

Might be seeing the Can pricing but it’s $19.5 or close enough on my Steam :wink:

image

Weird… I will check check…no clue at this moment…

But you can still grab a free key for the game :wink: 23 left.

Free steam key

1 Like