Skip to main content

🇬🇧 they/them, it/its 🇩🇪 es/ihm

Counterspell: I did a thing for Ludum Dare 51.

Title image for the game Counterspell

You play as the sorcerex who has invaded a lost tomb for a powerful relic, the Counterspell.

A rotating set of spells are cast every 10 seconds which you must use to escape the crypt. A game made for Ludum Dare 51 with the theme every 10 seconds.

Links:


This section below was actually written much much later after the game jam from notes that I had on my nextcloud but hadn't done anything with.

How'd it go? #

I'm overall pretty happy with how this turned out. I initially had planned to make this as a Compo game and made all the assets and code myself during the jam. The deciding factor was that the game at the end of the compo was just not... there. It needed some work.

Learnings #

Recording an entire game jam is a massive time sink. #

For the game jam I considered using this as an excuse to post something to youtube rather than having it as a holding account for video links.

Mostly out of paranoia from the first test recordings being terrible quality, I found myself constantly pausing recording to verify their quality.

I tried to make it low-effort by way of running obs on my main monitor, and recording microphone audio in lieu of taking notes and timestamps for later editing. The thought was I'd scrub over the video files later and edit them down. Naturally of course, this is a massive amount of work that I was never going to realistically do.

When looking over the files I had on hand, the one thing that I found myself missing was just clean audio and video from the game at various stages, which is thankfully supported in Godot thanks to this little guy:

Image shows a mouse cursor hovering over the Movie Maker Mode in Godot and the tooltip for the mode.

I think going forward I'll take progress snapshots using this mode and then either post them to social media, or splice them together into a time-lapse.

Custom resources are great, but having access to the scene tree is very powerful. #

So for a little while I was making a little clone of the Wii Tanks Minigame. In that game I used custom typed resources to handle the ai agents that looked like this:

TankBrainBase.gd

class_name TankBrainBase extends Resource

export var turret_type: String = "square";
export var tank_material_tint: Color;
export var bullet_template: Resource;
export var number_of_bullets_available: int = 5;

func initialize(_tank_body: KinematicBody):
	pass

func determine_driving_direction(
	_delta: float,
	_tank_body_transform: Spatial) -> Vector3:

	return Vector3.ZERO;

func determine_turrent_direction(
	_delta: float,
	_turret_transform: Spatial) -> Vector3:

	return Vector3.FORWARD;

func determine_should_fire(
	_delta: float,
	_shots_remaining: int,
	_turret_transform: Spatial) -> bool:

	return false;

There weren't really any ai implementations to speak of besides the Player brain that handled input, and one that periodically shot at random. The game didn't get that far. This worked decently, and I really liked the idea that you could just drag and drop unity-style but it did have the odd cruft.

Most notably the bullet spawning needed to happen on the KinematicBody since resources aren't in the scene tree. So the script on the kinematic body had a lot of jobs to do including spawning projectiles, handling the actor locomotion, and spawning death effects (which there was only one of).

A game like Wii Tanks, this is icky, but servicable. There really is only one set of visuals, everything can be constructed as having the same bullet projectile (the fiery bullet trail was determined just by the speed of the bullet) but I ran into some problems in Counterspell. I needed archers to spawn arrows, soldiers to play a little sweep animation, and the unfinished undead mage to perform spells like the player does.

Early into Saturday I started to run into this and had to adapt. What I ended up with is best explained first by looking at the scene structures of Warrior, and Archer, which inherits it (a residual of how I initially tried to solve this issue)

Comparison of the scene trees of the Skeleton Warrior and Skeleton Archer entities, which differ only in terms of sprites and what script is attached to the Brain node.

The script that is on the Brain node is the key differentiation between these two trees. The normal warrior, which was the only enemy until late Saturday used just EnemyBrainBase, below.

enemy_brain_base.gd

class_name EnemyBrainBase extends Node

enum Awareness {
	ALERT = 0,
	AWARE = 1,
	UNAWARE = 2
}


onready var attack_animation: AnimatedSprite = $"./AttackAnimation"
onready var attack_sound: AudioStreamPlayer2D = $"./AttackSound"

export var corpse: PackedScene
export var attack_damage: int = 1

var stage: GameStage
var owner_body: KinematicBody2D
var last_known_player_position: Vector2
var desired_position: Vector2
var current_awareness: int = Awareness.UNAWARE

func _ready():
    attack_animation.frame = attack_animation.frames.get_frame_count("default")

func on_stage_ready(game_stage: GameStage, owner: KinematicBody2D):
    stage = game_stage
    owner_body = owner

func process_unaware_state():
    current_awareness = Awareness.UNAWARE
    last_known_player_position = owner_body.position
    desired_position = owner_body.position

func process_aware_state():
    current_awareness = Awareness.AWARE
    last_known_player_position = stage.get_player_state().world_position
    desired_position = last_known_player_position

func process_alert_state():
    current_awareness = Awareness.ALERT
    desired_position = last_known_player_position

func get_desired_poisition() -> Vector2:
    return desired_position

func try_attack():
    if current_awareness != Awareness.AWARE:
        return

    if (owner_body.position.distance_to(stage.get_player_state().world_position) < 48
        && !stage.get_player_state().in_iframes):
        attack_animation.position = owner_body.position
        attack_animation.frame = 0
        attack_sound.position = owner_body.position
        attack_sound.play()
        stage.get_player_state().damage_player(attack_damage)

func take_damage(_amount: int, _source: String) -> bool:
    var corpse_instance = corpse.instance() as Node2D
    corpse_instance.position = owner_body.position
    owner_body.get_parent().add_child(corpse_instance)

    return true

The Archer used a derived script that basiclaly just changes where the Archer positions itself (further away, and it prefers to not be close to the player) as well as what happens when it attacks.

archer_brain.gd

extends EnemyBrainBase

export var arrow_range: int = 256
export var arrow_template: PackedScene

var can_shoot: bool = false

func on_stage_ready(game_stage: GameStage, owner: KinematicBody2D):
	.on_stage_ready(game_stage, owner)
	var _i = game_stage.get_timer().connect("timeout", self, "_on_timer_timeout")

func process_aware_state():
	.process_aware_state()
	if (last_known_player_position.distance_to(owner_body.position) > arrow_range):
		desired_position = owner_body.position.direction_to(last_known_player_position)
			* 0.6
			* arrow_range
	else:
		desired_position = owner_body.position

func try_attack():
	if current_awareness != Awareness.AWARE:
		return

	if (last_known_player_position.distance_to(owner_body.position) <= arrow_range
		&& can_shoot):
		can_shoot = false
		var arrow = arrow_template.instance() as KinematicBody2D
		arrow.position = owner_body.position
		arrow.rotation_degrees = rad2deg(
			owner_body.position.direction_to(
				last_known_player_position).angle())
		arrow.add_collision_exception_with(owner_body)
		owner_body.get_parent().add_child(arrow)
		attack_sound.play()

func _on_timer_timeout():
	can_shoot = true

So the major feature I got from this was being able to easily read the stage timer to tie it into the 10s counter, which I thought was cute.

The other one was that the ai script was now the only part that needed to be aware of the different attack type.

Now this is all game jam code so much of this could be componentized better, but swapping from Resources to Nodes and using known Node paths in the body controllers is a pattern I'm using going forward.

Results #

Image shows a mouse cursor hovering over the Movie Maker Mode in Godot and the tooltip for the mode.

37th in theme is pretty good considering the theme and how much of the game turns off of that 10s timer was what I was most interested in.

Some non-obvious things that tie into the loop:

  • The song is a 10s loop, synced to the timer
  • As you can see above, the Archer only gets a new arrow to shoot when the timer resets.
  • Up until Sunday, the Skeleton attack timer only reset after 10 seconds, which made the game very easy.
  • Up until early Saturday, enemies that were asleep would sleep for an entire cycle rather than just 1 second (again it made the game very easy)

I still intend to revisit this as a concept, I think at least having other casters, or even fixed traps tied to the timer would be interesting. And also being free from the jam the timer really really needs to be shorter to make the game a little more responsive.

Closing Thoughts #

This was the first jam I'd done since jettisoning all my old accounts, and the first one I completed with Godot. I think overall I feel more confident in Godot's future and its viability as a game engine for serious use.