Quazarquest
Main Developer
(March 2025 - April 2025)
Download link: [coming soon]
Programming responsibilities: Physics and systems programming, enemy and auto-player behavior, UI and GFX programmer, telemetry
Team Size: Solo
Misc. responsibilities: Pixel art and animation
Created of a resource-based system to allow for flexible and practical implementation of new players, ships, enemies, and projectiles
Allows user to easily swap between 6+ player types with unique weapons and physics
Iterated upon generalized behavior components that allowed for complex movement patterns, including using acceleration curves for physics, advanced targeting, and multi-segmented boss AI
Introduced telemetry options which emulates player inputs and records data into formatted .csv files
Using custom Godot Resources to easily create new behaviors and variations
Making the most of Godot’s tools and systems
When initially designing the systems to assign attributes to player and enemy ships, I almost fell into the same routine of creating different prefabs scenes to make the different variations. However, with the way Godot incentivizes the usage of component-based systems and resources, I wanted to find a way to more easily create different ships without having to make completely new objects with almost identical node structures. With a bit of research and experience with previous projects, I recognized this would be an ideal circumstance to make use of Godot’s custom Resource system.
The benefits of serializing attributes for ship movement
Rather than tethering all of a ship’s attributes to the script attached to the main character body, I created a custom “Ship Settings” Resource that handles all of that data instead. Not only does this provide a solution to the aforementioned problem, but it also makes it incredibly easy to instantiate new ships/projectiles (no need to store a scene, just the data) and change attributes on-the-fly (player character swapping). This system extends to the player, where information meant to be displayed on the UI, main and sub-weapons, and health regeneration speeds and thresholds are stored.
Serializing emitters get the same upsides despite different functionality
While creating the functionality for the player’s ability to shoot bullets, I came up with a generic “emitter” object that could be used to summon both game objects and graphical effects with ease. Modeled similarly to how particle systems are structured, emitters handle attributes such as initial angle and speed and their variance, burst firing and delay options, whether or not the starting angle is relative to its parent’s angle, and the scene itself to instantiate. These settings can also be changed on-the-fly (as seen with player swapping) and can contribute to advanced projectile and enemy behavior.
# Inside ShipController.gd @export_group("Object References") @export var shipInput : ShipInput @export var shipSettings : ShipSettings: set(value): shipSettings = value if shipSettings != null: apply_settings(shipSettings) ship_settings_changed.emit(shipSettings) # (more variables)...
Dynamic ship movement with acceleration curves
Acceleration curves create unique movement
As seen with the aforementioned Resources, player ships, enemy ships, and projectiles make use of various values and constraints for movement. When moving or turning, these controllers not only change their linear and angular velocity over time with acceleration values, but that acceleration is changed over time using Godot’s Curves data type. Affecting the change in acceleration over time — “jerk" — allows for even finer control over how a ship can move and proliferates emergent movement behaviors. Obtaining the correct value along the curve is straightforward, as all it takes is calling the “sample()” function with a value between 0 and 1.
Generalized input system allows for intuitive steering for game objects
For player ships, movement is controlled with a keyboard or gamepad using tank controls, which are stored in a 2D vector for handling movement. For enemies and projectiles, it uses the same input system, making use of a 2D vector for movement though getting those inputs through more complex logic. When targeting something, the ship controller will factor in its current angular velocity and acceleration in order to know when to speed up or slow down to within a specific margin of error towards its target. Ships also have an option to slow down when the angle between its target and itself is too great. Finally, for the auto-player system, it will actively chase targets until a specific distance, in which case it cranks itself in reverse while still aiming towards enemies and steering away from incoming attacks.
func handle_ship_movement(delta : float, time_scale : float = 1.0) -> void: if time_scale <= 0.0: return velocity = velocity / time_scale delta *= time_scale ## handle angle stuff var turnInput : float = shipInput.input.x if (health == null or health != null and health.healthState == Health.HealthState.ALIVE) else 0.0 # find curve amount if abs(turnInput) > 0: if (sign(angularAcceleration) == 0 or sign(turnInput) == sign(angularAcceleration)): angularAccelerationTimer += delta * turnInput else: angularAccelerationTimer = 0 var aaccelCurveValue = angularAccelerationCurve.sample(abs(angularAccelerationTimer)/angularAccelerationTime)*sign(angularAccelerationTimer) # turn stuff if not pointAtTarget: if abs(turnInput) > 0: if angularVelocity == 0 or sign(angularVelocity) == sign(angularAcceleration): angularAcceleration = maximumAngularAcceleration * aaccelCurveValue else: angularAcceleration = (maximumAngularAcceleration + angularFriction) * aaccelCurveValue angularVelocity += angularAcceleration * delta else: angularAccelerationTimer = 0 angularAcceleration = angularFriction * -sign(angularVelocity) if (sign(angularVelocity) != sign(angularVelocity + angularAcceleration * delta) or angularVelocity == 0.0): angularVelocity = 0.0 angularAcceleration = 0.0 else: angularVelocity += angularAcceleration * delta ## handle linear velocity var thrustInput : float = -shipInput.input.y if (health == null or health != null and health.healthState == Health.HealthState.ALIVE) else 0.0 # find curve amount if abs(thrustInput) > 0: if (sign(linearAcceleration) == 0 or sign(thrustInput) == sign(linearAcceleration)): linearAccelerationTimer += delta * thrustInput else: linearAccelerationTimer = 0 var laccelCurveValue = linearAccelerationCurve.sample(abs(linearAccelerationTimer)/linearAccelerationTime)*sign(linearAccelerationTimer) # thrust stuff if abs(thrustInput) > 0: if linearVelocity == 0 or sign(linearVelocity) == sign(linearAcceleration): linearAcceleration = maximumLinearAcceleration * laccelCurveValue else: linearAcceleration = (maximumLinearAcceleration + linearFriction) * laccelCurveValue linearVelocity += linearAcceleration * delta else: linearAccelerationTimer = 0 linearAcceleration = linearFriction * -sign(linearVelocity) if (sign(linearVelocity) != sign(linearVelocity + linearAcceleration * delta) or linearVelocity == 0.0): linearVelocity = 0.0 linearAcceleration = 0.0 else: linearVelocity += linearAcceleration * delta #print("linear accel: %.02f, linear velocity: %.02f" % [linearAcceleration, linearVelocity]) ## actually move # define extra velocity if extraVelocity.length() > 0: var newExVel : Vector2 = extraVelocity.move_toward(Vector2.ZERO, delta*linearFriction) if newExVel.length() > delta: extraVelocity = newExVel else: extraVelocity = Vector2.ZERO if not pointAtTarget: angle += angularVelocity * delta else: var diff = shipInput.get_target_position()-global_position angle = rad_to_deg(diff.angle()) rotation_degrees = angle #velocity = Vector2.from_angle(deg_to_rad(angle)) * linearVelocity if controlType != shipSettings.controlType: controlType = shipSettings.controlType match(controlType): ShipSettings.ShipControlType.TANK: velocity = Vector2.from_angle(deg_to_rad(angle)) * linearVelocity + extraVelocity '''if health == null: velocity = Vector2.from_angle(deg_to_rad(angle)) * linearVelocity + extraVelocity else: velocity = lerp(Vector2.from_angle(deg_to_rad(angle)) * linearVelocity, extraVelocity.normalized()*(extraVelocity.length() + linearVelocity), health.currentIFrames/health.maxIFrames if health.maxIFrames > 0.0 else 0.0)''' ShipSettings.ShipControlType.ASTEROIDS: if abs(thrustInput) > 0: velocity += Vector2.from_angle(deg_to_rad(angle)) * linearAcceleration if velocity.length() > maximumLinearVelocity: velocity = velocity.normalized() * maximumLinearVelocity else: if (velocity.length() - linearFriction*delta) > 0.0: velocity = velocity.normalized() * max(0.0, velocity.length() - linearFriction*delta) velocity += extraVelocity velocity *= time_scale
# Inside function "handle_movement()" #... # obtaining the correct angular acceleration value var aaccelCurveValue = angularAccelerationCurve.sample(abs(angularAccelerationTimer)/angularAccelerationTime)*sign(angularAccelerationTimer) # (insert movement logic here)... # obtaining the correct linear acceleration value var laccelCurveValue = linearAccelerationCurve.sample(abs(linearAccelerationTimer)/linearAccelerationTime)*sign(linearAccelerationTimer) #...