xkcd figure: The Stick Figure Guide

ToledoEM

2026-08-19

Code
library(xkcd)
library(ggplot2)

How xkcdman works

The wild anatomy of a stick figure.

xkcdman() draws a stick figure defined entirely by angles (in radians) for each body segment. The figure has 8 parts:

Parameter Body part
angleofneck neck — connects head to top of spine
angleofspine spine — main torso
anglerighthumerus right upper arm
anglerightradius right forearm
anglelefthumerus left upper arm
angleleftradius left forearm
anglerightleg right leg
angleleftleg left leg

Two other parameters control size and proportion:

All angles follow the standard mathematical convention: 0 = right, π/2 = up, π = left, 3π/2 (or −π/2) = down.


Part 1 — Anatomy diagram

Each bone is drawn separately with its own color so you can see exactly what each parameter controls. The helper below draws a single labeled figure with colored segments.

Code
# --- geometry helper: compute bone endpoints from the same formulas as xkcdman
make_figure <- function(x, y, scale, ratioxy,
                        angleofneck, angleofspine,
                        anglerighthumerus, anglerightradius,
                        anglelefthumerus,  angleleftradius,
                        anglerightleg,     angleleftleg) {

  dh  <- scale                    # head diameter
  ls  <- dh                       # spine length
  ll  <- ls * 1.2                 # leg length
  lh  <- ls * 0.6                 # humerus length
  lr  <- ls * 0.5                 # radius length

  # key joint positions
  shoulder <- c(x, y) +
    (dh / 2) * c(cos(angleofneck) * ratioxy, sin(angleofneck))
  hip <- shoulder +
    ls * c(cos(angleofspine) * ratioxy, sin(angleofspine))
  end_rh <- shoulder +
    lh * c(cos(anglerighthumerus) * ratioxy, sin(anglerighthumerus))
  end_lh <- shoulder +
    lh * c(cos(anglelefthumerus)  * ratioxy, sin(anglelefthumerus))

  seg <- function(from, angle, dist)
    data.frame(
      x    = from[1],
      y    = from[2],
      xend = from[1] + dist * cos(angle) * ratioxy,
      yend = from[2] + dist * sin(angle)
    )

  list(
    head    = data.frame(x = x, y = y, diameter = dh),
    spine   = seg(shoulder, angleofspine,       ls),
    rarm1   = seg(shoulder, anglerighthumerus,  lh),
    rarm2   = seg(end_rh,   anglerightradius,   lr),
    larm1   = seg(shoulder, anglelefthumerus,   lh),
    larm2   = seg(end_lh,   angleleftradius,    lr),
    rleg    = seg(hip,      anglerightleg,       ll),
    lleg    = seg(hip,      angleleftleg,        ll)
  )
}

# Standard upright pose — large figure centred in the plot
xrange <- c(0, 10)
yrange <- c(0, 10)
rxy    <- diff(xrange) / diff(yrange)
sc     <- 2.5   # bigger figure fills the space

f <- make_figure(
  x = 5, y = 7.2, scale = sc, ratioxy = rxy,
  angleofneck       = -pi / 2,
  angleofspine      = -pi / 2,
  anglerighthumerus = -pi / 6,
  anglerightradius  =  pi / 6,
  anglelefthumerus  = -pi / 2 - pi / 6,
  angleleftradius   = -pi / 6,
  anglerightleg     =  3 * pi / 2 - pi / 12,
  angleleftleg      =  3 * pi / 2 + pi / 12
)

colours <- c(
  head  = "#e41a1c",
  spine = "#377eb8",
  rarm1 = "#ff7f00",
  rarm2 = "#ff7f00",
  larm1 = "#4daf4a",
  larm2 = "#4daf4a",
  rleg  = "#984ea3",
  lleg  = "#a65628"
)

set.seed(99)
p_anat <- ggplot() + coord_fixed(xlim = xrange, ylim = yrange)

# head
p_anat <- p_anat +
  geom_xkcdpath(aes(x = x, y = y, diameter = diameter),
                data = f$head, colour = colours["head"],
                linewidth = 1.4, ratioxy = rxy, mask = FALSE)

# bones
bones <- c("spine", "rarm1", "rarm2", "larm1", "larm2", "rleg", "lleg")
for (b in bones) {
  p_anat <- p_anat +
    geom_xkcdpath(aes(x = x, y = y, xend = xend, yend = yend),
                  data = f[[b]], colour = colours[b],
                  linewidth = 1.4, mask = FALSE,
                  xjitteramount = 0.02, yjitteramount = 0.02)
}

library(ggrepel)

# Label origins at bone midpoints, nudged outward from figure centre
labels_df <- data.frame(
  x = c(
    f$head$x,
    (f$spine$x  + f$spine$xend)  / 2,
    (f$rarm1$x  + f$rarm1$xend)  / 2,
    (f$rarm2$x  + f$rarm2$xend)  / 2,
    (f$larm1$x  + f$larm1$xend)  / 2,
    (f$larm2$x  + f$larm2$xend)  / 2,
    (f$rleg$x   + f$rleg$xend)   / 2,
    (f$lleg$x   + f$lleg$xend)   / 2
  ),
  y = c(
    f$head$y,
    (f$spine$y  + f$spine$yend)  / 2,
    (f$rarm1$y  + f$rarm1$yend)  / 2,
    (f$rarm2$y  + f$rarm2$yend)  / 2,
    (f$larm1$y  + f$larm1$yend)  / 2,
    (f$larm2$y  + f$larm2$yend)  / 2,
    (f$rleg$y   + f$rleg$yend)   / 2,
    (f$lleg$y   + f$lleg$yend)   / 2
  ),
  label = c("head / scale", "angleofspine",
            "anglerighthumerus", "anglerightradius",
            "anglelefthumerus",  "angleleftradius",
            "anglerightleg",     "angleleftleg"),
  col   = unname(colours[c("head","spine","rarm1","rarm2","larm1","larm2","rleg","lleg")]),
  # nudge labels away from figure: right side +x, left side -x, head up
  nx    = c( 2.0,  0.3,  2.5,  2.8, -2.5, -2.8, -2.0,  2.0),
  ny    = c( 1.2,  0,    0.4, -0.5,  0.4, -0.5, -0.5, -0.8)
)

p_anat <- p_anat +
  geom_text_repel(
    aes(x = x, y = y, label = label, colour = col),
    data               = labels_df,
    nudge_x            = labels_df$nx,
    nudge_y            = labels_df$ny,
    family             = "xkcd",
    size               = 4,
    box.padding        = unit(0.1, "lines"),
    point.padding      = unit(0.1, "lines"),
    force              = 1,
    min.segment.length = 0.3,
    segment.size       = 0.4,
    segment.colour     = "grey50",
    seed               = 42,
    show.legend        = FALSE
  ) +
  scale_colour_identity()

p_anat <- p_anat +
  annotate("text", x = 5, y = 9.3,
           label = "xkcdman anatomy", family = "xkcd", size = 6) +
  annotate("text", x = 5, y = 8.9,
           label = "angleofneck connects head to shoulder",
           family = "xkcd", size = 3, colour = "grey40") +
  theme_xkcd() +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        axis.title = element_blank())

p_anat


Part 3 — Angle reference

The figure below shows the same upright figure four times, varying one parameter at a time across a row, so you can see directly how each angle value changes the pose.

Code
# Neutral (upright) pose used as baseline
neutral <- list(
  angleofneck       = -pi/2,
  angleofspine      = -pi/2,
  anglerighthumerus = -pi/6,
  anglerightradius  =  pi/6,
  anglelefthumerus  = -pi/2 - pi/6,
  angleleftradius   = -pi/6,
  anglerightleg     =  3*pi/2 - pi/12,
  angleleftleg      =  3*pi/2 + pi/12
)

params_to_vary <- list(
  list(p = "angleofspine",
       v = c(-pi/2, -pi/2 + pi/6, -pi/2 + pi/3, 0),
       l = c("spine: -pi/2\n(upright)", "spine: -pi/3", "spine: -pi/6", "spine: 0\n(horizontal)")),
  list(p = "anglerighthumerus",
       v = c(-pi/6, 0, pi/4, pi/2),
       l = c("rarm: -pi/6\n(rest)", "rarm: 0\n(right)", "rarm: pi/4", "rarm: pi/2\n(up)")),
  list(p = "anglerightleg",
       v = c(3*pi/2 - pi/12, 3*pi/2 - pi/4, 3*pi/2 + pi/4, 3*pi/2 - pi/2),
       l = c("rleg: rest", "rleg: back", "rleg: forward", "rleg: up")),
  list(p = "anglelefthumerus",
       v = c(-pi/2-pi/6, pi-pi/6, pi/2+pi/6, pi),
       l = c("larm: rest", "larm: left", "larm: up-left", "larm: out"))
)

ref_segs  <- do.call(rbind, lapply(params_to_vary, function(pv) {
  r <- vary_angle(pv$p, pv$v, pv$l)
  r$segs$group_param <- pv$p
  r$segs
}))

ref_heads <- do.call(rbind, lapply(params_to_vary, function(pv) {
  r <- vary_angle(pv$p, pv$v, pv$l)
  r$heads$group_param <- pv$p
  r$heads
}))

ref_segs$colour  <- part_colours[ref_segs$part]
ref_heads$colour <- part_colours["head"]

# highlighted part per param group
highlight_map <- c(
  angleofspine      = "#377eb8",
  anglerighthumerus = "#ff7f00",
  anglerightleg     = "#984ea3",
  anglelefthumerus  = "#4daf4a"
)

ref_segs$colour <- ifelse(
  (ref_segs$part == "spine"  & ref_segs$group_param == "angleofspine") |
  (ref_segs$part == "rarm1"  & ref_segs$group_param == "anglerighthumerus") |
  (ref_segs$part == "rarm2"  & ref_segs$group_param == "anglerighthumerus") |
  (ref_segs$part == "rleg"   & ref_segs$group_param == "anglerightleg") |
  (ref_segs$part == "larm1"  & ref_segs$group_param == "anglelefthumerus") |
  (ref_segs$part == "larm2"  & ref_segs$group_param == "anglelefthumerus"),
  ref_segs$colour,
  "grey70"
)

# Facet label: combine group_param + pose
ref_segs$facet_label  <- paste0(ref_segs$group_param,  "\n", ref_segs$pose)
ref_heads$facet_label <- paste0(ref_heads$group_param, "\n", ref_heads$pose)

# Build ordered factor across all 16 facets
facet_order <- unlist(lapply(params_to_vary, function(pv)
  paste0(pv$p, "\n", pv$l)
))
ref_segs$facet_label  <- factor(ref_segs$facet_label,  levels = facet_order)
ref_heads$facet_label <- factor(ref_heads$facet_label, levels = facet_order)

set.seed(7)

p_ref <- ggplot() +
  geom_xkcdpath(
    aes(x = x, y = y, xend = xend, yend = yend, colour = colour),
    data = ref_segs,
    linewidth = 1.2, mask = FALSE,
    xjitteramount = 0.03, yjitteramount = 0.03,
    inherit.aes = FALSE
  ) +
  geom_xkcdpath(
    aes(x = x, y = y, diameter = diameter),
    data = ref_heads,
    linewidth = 1.2, colour = "#e41a1c", ratioxy = 1, mask = FALSE,
    inherit.aes = FALSE
  ) +
  scale_colour_identity() +
  facet_wrap(~ facet_label, ncol = 4) +
  coord_fixed(xlim = xr, ylim = yr) +
  theme_xkcd() +
  theme(
    axis.text  = element_blank(),
    axis.ticks = element_blank(),
    axis.title = element_blank(),
    strip.text = element_text(family = "xkcd", size = 9)
  ) +
  labs(title = "Angle reference: one parameter varied per row")

p_ref


Quick reference card

angleofspine      -pi/2  upright  |  0  horizontal  |  pi/2  upside down
angleofneck       match angleofspine for natural look
anglerighthumerus -pi/6  hanging  |  0  right  |  pi/2  raised
anglerightradius  follow anglerighthumerus + pi/6 for natural elbow bend
anglelefthumerus  mirror of right: -pi/2 - anglerighthumerus
angleleftradius   mirror of right
anglerightleg     3*pi/2 - pi/12   (slight outward spread)
angleleftleg      3*pi/2 + pi/12   (slight outward spread)

scale     ~10-15% of diff(yrange)
ratioxy   diff(xrange) / diff(yrange)   -- always set this!