< up >
2023-08-24

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.

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:

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

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

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:

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:


  1. Johannes and I
  2. Well, they also have a non-deterministic while loop preventing overlapping jacks.
  3. I recorded them while pluging some eurorack cables in and out.
  4. While writing this it remembers me a little bit of ray tracing.