Counterspell: I did a thing for Ludum Dare 51.
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:
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)
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.
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.
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 #
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.