Godot Wild Jam 60
We1 participated at the Godot Wild Jam #60 with the theme malfunction and submitted Rebot Roboot: Repair your robotic companion after a failed firmware update.
- jam submission
- game
- source
- soundtrack: Mandarin Marshmallow
- one wildcard implemented: Mobius loop Your game starts and ends at the same point.
Contents
Idea
Your robotic companion has malfunctions after a failed firmware update and needs to be repaired. Each of the four malfunctions corresponds to one minigame that needs to be resolved in order to make the robot fully functional again.
The main scene is a lab with monitors to toggle music and the boot sequence and a table with icons to all minigames. We decided to not immediately show the success of a mini game to increase the tension. The player can press reboot on the right monitor which triggers a test suite validating all minigames sequentially. If one validation fails, an error message is printed for a few seconds and all minigames were reset. Each minigame has its own validation function.
Minigame mechanics
Wires
Connect all the jacks pair-wise having the same color by drag-and-dropping wires.
Each jack has a color and is instanciated within Godot. They get randomly positioned on startup within the jack boundary aligned2 to a grid via modulo (so they look more circuit alike):
# Called when the node enters the scene tree for the first time.
func _ready():
var taken :Array
for node in $Jacks.get_children():
node = node as Jack
while true:
var x_offset = randi_range(
$JackBoundary.position.x - $JackBoundary.shape.get_rect().size.x/2,
$JackBoundary.position.x + $JackBoundary.shape.get_rect().size.x/2
)
var y_offset = randi_range(
$JackBoundary.position.y - $JackBoundary.shape.get_rect().size.y/2,
$JackBoundary.position.y + $JackBoundary.shape.get_rect().size.y/2
)
var new_position = Vector2(x_offset - x_offset % 20,y_offset - y_offset % 20)
if not new_position in taken:
node.position = new_position
taken.append(new_position)
break
On clicking on a jack to start connecting two jacks, a new Cable
node gets added to the cables list. Those cable is basically a Line2D
which also preserves the start and end jack such as validator.
The fiddly part was the drag-and-drop logic within the mouse event handler:
- focus tracking: each jack is tracking its focus on its own using a collision shape and
Area2D
’smouse_entered
andmouse_exited
events - disconnect previous cables: if the player wants to start a new cable on a jack that is already connected, the previous cables get removed
- jack sound: after clicking on a jack a randomly chosen “plug-in” sound gets played3
- movement: as the mouse moves while having a cable connected to a starting jack, the end position of the cable is just set to the mouse cursor ones
Validation
The validator for the wires minigame checks if there are enough cables connected (count of jacks/2) such as if the start and end Jack
of every cable has the same color:
func validate() -> String:
var children := $Cables.get_children()
if len(children) != $Jacks.get_child_count() / 2:
return "cables not connected"
for cable in children:
if cable.source.modulate != cable.destination.modulate:
return "wrong connection"
return ""
Logic gates
Flip the input bits so the output big becomes
The logical circuit is a graph with Gate
nodes as nodes and GateConnection
as edges. Both is instantiated within the Godot scene graph while the Gates can be placed within the scene and the GateConnections are automatically drawn on run-time. The graph is built using export variables of both Gates and GateConnections. Both nodes have an output
function basically “pulling” the signal recursively from sink to source4:
# Gate.gd
func output() -> bool:
match type:
GateTypeEnum.ZERO: return false
GateTypeEnum.ONE: return true
GateTypeEnum.AND:
# grab both inputs before doing the check
# in order to update both paths
var a = input1.output()
var b = input2.output()
return a and b
GateTypeEnum.OR:
var a = input1.output()
var b = input2.output()
return a or b
GateTypeEnum.NOT: return !input1.output()
GateTypeEnum.OUT:
var out = input1.output()
if !out:
out_audio_played = false
if !out_audio_played and out:
out_audio_played = true
$OutAudio.play()
update_texture(out)
return out
GateTypeEnum.IN:
update_texture(input_value)
return input_value
return false
# GateConnection.gd
func output() -> bool:
cached_out = source.output()
return cached_out
- prefetching inputs: the inputs for
AND
andOR
need to be fetched before evaluation to avoid short-circuit evaluation where the second argument doesn’t get evaluated if the first doesn’t satisfy the expression. - signal caching: the GateConnection caches the output value, so its
process
can use it for coloring the cable - recursive updates: the output
Gate
callsoutput
periodically within itsprocess
to update all Gates and GateConnections
Validation
It just needs to be checked if the output gate is set to 1.
Optics
Reflect the laser beam with the mirrors so the output block gets lit.
The mirrors are essentially rotatable collision shapes. The path of the laser beam is continuously recalculated within the minigame’s process
using a RayCast2D
:
func _process(delta)
# clear the laser beam
$LaserBeam.clear_points()
# let the lazer beam start at the position of the emitter
$LaserBeam.add_point($Emitter.global_position)
# position the ray cast initially to the emitters position
# pointing to the right towards the first mirror
$RayCast.global_position = $Emitter.global_position
$RayCast.target_position = Vector2(300,0)
var reflect_target: Vector2
# loop as long as the raycast hits a mirror
# and break if it collides with the output block or with nothing at all
while true:
# force the ray cast to walk from the global position towards the target
# finding the first collision (or none if there is nothing to collide with)
$RayCast.force_raycast_update()
# if ray cast points beyond the minigame window bounds, no collission will happen
if !$RayCast.is_colliding():
$LightSink.unhighlight()
$LaserBeam.add_point($RayCast.global_position + $RayCast.target_position)
break
# we know that we've collided with something, so add the point to the
# laser beam
$LaserBeam.add_point($RayCast.get_collision_point())
if $RayCast.get_collider().name == "WallArea":
break
# the output block has been reached!
if $RayCast.get_collider() == $LightSink/Area2D:
$LightSink.highlight()
break
# let the laser beam bounce of the mirror inspired by
# https://github.com/Remysaurus/GodotReflectingLaser3D/blob/main/addons/laserGit/laser.gd#L75
reflect_target = (
$RayCast.get_collision_point() - $RayCast.global_position
).bounce($RayCast.get_collision_normal())
# place the ray cast to collission point so the next iteration can find the next collision
# add a small offset towards the target, otherwise the raycast will immediately collide
# with the previous mirror
$RayCast.global_position = $RayCast.get_collision_point() + reflect_target.normalized()*2
$RayCast.target_position = reflect_target.normalized()*300
$LaserSoundPlayer.pitch_scale = log($LaserBeam.points.size())*2
Additionally the last line increases the pitch of the “laser sound” for each mirror which got hit by the laser.
Validation
The validate just need to look if the output block (aka light sink) is lit:
func validate() -> String:
return "" if $LightSink.highlighted else "target did not receive laser beam"
Brain
Dial the knobs so that the robot redraws the house. The brain aka visual cortex memes an image-detection program that tries to redraw the seen image as accurate as possible.
The house is manually drawn within godot using a Line2D
, called GoalLine
. The RobotLine
consists of all points from GoalLine
but transformed using values from the dial:
update_robot_line():
$RobotLine.clear_points()
var index: int = 0
for point in $GoalLine.points:
point = point as Vector2
var robotPoint = point.rotated(
float( $KnobA.value()) / 50
) * 0.5 * float($KnobB.value())
if index % 2 == 0:
robotPoint += Vector2($KnobC.value() + 0.3, $KnobC.value() + 0.2)
else:
robotPoint -= Vector2($KnobC.value() - 0.3, $KnobC.value() - 0.2)
$RobotLine.add_point(robotPoint)
index += 1
While the transformations of translating, scaling and rotating was intended, we needed to guess the coefficients to keep the transformation mostly within the viewport.
Within the minigames process
, the RobotLine
gets transformed to the ShowLine
which is the red “cathode ray tube”-alike Line2D that is actually shown to the player:
func _process(delta):
if last_update + delta < 0.1:
last_update += delta
return
last_update = 0
if point_index >= $RobotLine.points.size()-2:
point_index = 0
if $RobotLine.get_point_count() > 0:
$ShowLine.add_point($RobotLine.get_point_position(point_index))
$ShowLine.add_point($RobotLine.get_point_position(point_index+1))
for n in max(0,$ShowLine.points.size()-5):
$ShowLine.remove_point(0)
point_index += 1
- speed control: to avoid stroboscopic effects and keep a constant speed the process is only executed every 100ms by tracking the last update
- show only three points: this could’ve been implemented easier but a globally tracked
point_index
is used to add only three points starting from the the index. The index gets incremented so the next iteration will proceed within the Line2D.
Validation
This is the trickiest validation, as it is hard to exactly match the given line as dialing the knobs is a bit difficult. In order to keep the player’s frustration rate low, the transformed points should match within a given error.
Since the order of the original and the transformed points is preserved, we know that ideally both lines should exactly match. To ease the process, we calculate a pair-wise distance and check if it is lower than a predefined tolerance:
func validate() -> String:
const TOLERANCE = 10
var player: Line2D = $RobotLine
var goal: Line2D = $GoalLine
for i in range(0, player.get_point_count()):
var p_pos := player.get_point_position(i)
var g_pos := goal.get_point_position(i)
if p_pos.distance_to(g_pos) > TOLERANCE:
return "image not aligned"
return ""
Summary
It was great fun doing different minigames within one week. They had all different challenges which solutions I now can add to my Godot skills:
- wires: drag and drop
- logic gates: implementing graphs and recursive evaluation
- optics: ray casting
- brain: connecting dials with a transformation matrix and animate a “slow cathode ray tube”
The logic gates could be more simple by moving the connection into the gate. The Gates could just track where they get their input/s from and draw the connection Line2D
by themselves.
Developing the optics minigame was most enjoyable since could finally get my hands dirty with ray tracing and also got the most positive feedback from friends and itch.io.
Finally it was a pleasure to make all the textures and music by our own together with Johannes. I’m looking forward for upcoming Pour Entertainment ssubmissions!
At the very end I’ll give you some insight about our manual review issue tracking:
- Johannes and I
- Well, they also have a non-deterministic while loop preventing overlapping jacks.
- I recorded them while pluging some eurorack cables in and out.
- While writing this it remembers me a little bit of ray tracing.