       @compositeImage frame, bubble, left, top
       top += bubble.height + topPad
 
-  # Build a single panel out of (UP TO) two lines of dialog
-  # cb invoked as `cb(err, image)`. `err` is only set on failure
-  buildPanel: (lines, cb) =>
-    # Setup a transparant frame that we'll composite characters and text into.
-    frame = GD.createTrueColor(AYP_PANEL_WIDTH, AYP_PANEL_WIDTH)
+  # Produce a text bubble that contains `msg` printed in the
+  # font `font` in the size `size`. The bubble will be at most
+  # `max` pixels wide, and will be padded according to `AYP_TEXT_PADDING`
+  #
+  # The return value will be a GD image.
+  textBubble: (msg, font=FONT_PATH, size=AYP_FONT_SIZE, max=AYP_BUBBLE_MAX_WIDTH) =>
+    msg = @formatForTextBubble(msg, font, size, max)
+    [w, h] = @textSize(msg, font, size)
+
+    frame = GD.createTrueColor(
+      w + (AYP_TEXT_PADDING * 2),
+      h + (AYP_TEXT_PADDING * 2)
+    )
     frame.saveAlpha(1)
-    clear = frame.colorAllocateAlpha(0, 0, 0, 127)
-    frame.fill(0, 0, clear)
-
-    # We can just return the empty transperant
-    # frame if we get no lines
-    return cb(false, frame) unless lines.length > 0
-
-    names = (l[0] for l in lines).
-      filter((v, i, a) -> a.indexOf(v) == i) # De-dupe
-
-    @loadAvatars names, (err, avatars) =>
-      return cb(err, null) if err
-
-      if names.length == 1
-        # The only person speaking is centered in the frame
-        char = avatars[names[0]]
-        left = (frame.width / 2) - (char.width / 2)
-        top = (frame.height - char.height)
-        @compositeImage frame, char, left, top
-      else
-        # We have two speakers
-        first = true
-        for line in lines
-          [who, what] = line
-          char = avatars[who]
-          top = (frame.height - char.height)
-
-          if first
-            left = 0
-            first = false
-          else
-            left = frame.width - char.width
-
-          @compositeImage frame, char, left, top
-
-      # Add the text after all the avatars are painted on
-      @drawPanelText frame, lines
-
-      # Return the frame to the caller
-      return cb(false, frame)
+    white = frame.colorAllocate(0xff, 0xff, 0xff)
+    black = frame.colorAllocate(0x00, 0x00, 0x00)
+    frame.fill(0, 0, white)
+
+    frame.stringFT(black, font, size,
+      0,                           # Rotation angle
+      AYP_TEXT_PADDING,            # x
+      AYP_TEXT_PADDING + size + 1, # y
+      msg
+    )
+    return frame
+
+  ##
+  ## Helpers and utilities
+
+  # Load avatars for `names` and invoke `cb` as:
+  # `cb(err, {"Nickname": avatarImg, ...})`
+  # mapping each name to an avatar image that
+  # can be used to represent it.
+  #
+  # `err` is only set on failure
+  loadAvatars: (names, cb) =>
+    # Prepare requirements
+    names = names.map (n) -> {name: n, img: null, err: null}
+    failed = false
+
+    fail = (err) ->
+      cb(err, null)
+      return faliled = true
+
+    charPathForNick = (nick) ->
+      potential = path.resolve(AVATAR_BASE, "#{nick.toLowerCase()}.png")
+      return potential if (try fs.statSync(potential))
+      return path.resolve(AVATAR_BASE, "default.png")
+
+    for nameObj in names
+      do (nameObj) ->
+        GD.openPng charPathForNick(nameObj.name), (err, img) ->
+          return if failed
+          return fail(err) if err
+          nameObj.img = img unless err
+
+          # Are we done? Then build up a dictionary
+          # and tell the caller.
+          if names.every((o) -> o.img)
+            avatars = {}
+            for obj in names
+              avatars[obj.name] = obj.img
+            cb(null, avatars)
 
   # Returns the bounding box of `msg`
   # When printed using `font` of `size`

    else
      msg

  # Produce a text bubble that contains `msg` printed in the
  # font `font` in the size `size`. The bubble will be at most
  # `max` pixels wide, and will be padded according to `AYP_TEXT_PADDING`
  #
  # The return value will be a GD image.
  textBubble: (msg, font=FONT_PATH, size=AYP_FONT_SIZE, max=AYP_BUBBLE_MAX_WIDTH) =>
    msg = @formatForTextBubble(msg, font, size, max)
    [w, h] = @textSize(msg, font, size)

    frame = GD.createTrueColor(
      w + (AYP_TEXT_PADDING * 2),
      h + (AYP_TEXT_PADDING * 2)
    )
    frame.saveAlpha(1)
    white = frame.colorAllocate(0xff, 0xff, 0xff)
    black = frame.colorAllocate(0x00, 0x00, 0x00)
    frame.fill(0, 0, white)

    frame.stringFT(black, font, size,
      0,                           # Rotation angle
      AYP_TEXT_PADDING,            # x
      AYP_TEXT_PADDING + size + 1, # y
      msg
    )
    return frame

  # Composite `sprite` onto `dst` in full.
  # Offsets `sprite` `+left` from the left
  # and `+top` from the top
  compositeImage: (dst, sprite, left, top) ->

    @buildPanel @script[2..3], (err, panel) -> finishPanel(err, 1, panel)
    @buildPanel @script[4..5], (err, panel) -> finishPanel(err, 2, panel)

  # Build a single panel out of (UP TO) two lines of dialog
  # cb invoked as `cb(err, image)`. `err` is only set on failure
  buildPanel: (lines, cb) =>
    # Setup a transparant frame that we'll composite characters and text into.
    frame = GD.createTrueColor(AYP_PANEL_WIDTH, AYP_PANEL_WIDTH)
    frame.saveAlpha(1)
    clear = frame.colorAllocateAlpha(0, 0, 0, 127)
    frame.fill(0, 0, clear)

    # We can just return the empty transperant
    # frame if we get no lines
    return cb(false, frame) unless lines.length > 0

    names = (l[0] for l in lines).
      filter((v, i, a) -> a.indexOf(v) == i) # De-dupe

    @loadAvatars names, (err, avatars) =>
      return cb(err, null) if err

      if names.length == 1
        # The only person speaking is centered in the frame
        char = avatars[names[0]]
        left = (frame.width / 2) - (char.width / 2)
        top = (frame.height - char.height)
        @compositeImage frame, char, left, top
      else
        # We have two speakers
        first = true
        for line in lines
          [who, what] = line
          char = avatars[who]
          top = (frame.height - char.height)

          if first
            left = 0
            first = false
          else
            left = frame.width - char.width

          @compositeImage frame, char, left, top

      # Add the text after all the avatars are painted on
      @drawPanelText frame, lines

      # Return the frame to the caller
      return cb(false, frame)

  # Draw the text into a `frame` described by
  # the `@script`
