Skip to contents

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:

  • scale — diameter of the head (all other segments are proportional to it). Set it to roughly 10–15% of diff(yrange).
  • ratioxydiff(xrange) / diff(yrange). Corrects for axis scaling so the figure is not stretched horizontally or vertically.

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.

# --- 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


Six figures in a faceted layout, each with a different pose. Every figure uses the same helper so we only specify the angles that change.

# Helper: return list(segs = df, head = df) for one pose
pose_segments <- function(pose_name, x, y, scale, ratioxy,
                          angleofneck, angleofspine,
                          anglerighthumerus, anglerightradius,
                          anglelefthumerus,  angleleftradius,
                          anglerightleg,     angleleftleg) {

  f <- make_figure(x, y, scale, ratioxy,
                   angleofneck, angleofspine,
                   anglerighthumerus, anglerightradius,
                   anglelefthumerus,  angleleftradius,
                   anglerightleg,     angleleftleg)

  segs <- do.call(rbind, lapply(
    c("spine","rarm1","rarm2","larm1","larm2","rleg","lleg"),
    function(b) { d <- f[[b]]; d$part <- b; d }
  ))
  segs$pose <- pose_name

  head_df <- data.frame(x = f$head$x, y = f$head$y,
                        diameter = f$head$diameter,
                        xend = NA_real_, yend = NA_real_,
                        part = "head", pose = pose_name)

  list(segs = segs, head = head_df)
}

# Helper: vary one angle across values, return list(segs=df, heads=df)
vary_angle <- function(param, values, labels) {
  all_segs  <- NULL
  all_heads <- NULL
  for (i in seq_along(values)) {
    args <- neutral
    args[[param]] <- values[i]
    r <- pose_segments(
      pose_name         = labels[i],
      x = 5, y = 7.2, scale = 1.4, ratioxy = 1,
      angleofneck       = args$angleofneck,
      angleofspine      = args$angleofspine,
      anglerighthumerus = args$anglerighthumerus,
      anglerightradius  = args$anglerightradius,
      anglelefthumerus  = args$anglelefthumerus,
      angleleftradius   = args$angleleftradius,
      anglerightleg     = args$anglerightleg,
      angleleftleg      = args$angleleftleg
    )
    all_segs  <- rbind(all_segs,  r$segs)
    all_heads <- rbind(all_heads, r$head)
  }
  list(segs = all_segs, heads = all_heads)
}

# Common geometry: square data space
xr  <- c(0, 10); yr <- c(0, 10); rxy <- 1; sc <- 1.4

poses <- list(
  # 1. Standing at rest
  pose_segments("Standing",
    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),

  # 2. Arms raised (cheering)
  pose_segments("Arms up",
    x = 5, y = 7.2, scale = sc, ratioxy = rxy,
    angleofneck       = -pi/2,
    angleofspine      = -pi/2,
    anglerighthumerus =  pi/3,
    anglerightradius  =  pi/2,
    anglelefthumerus  =  pi - pi/3,
    angleleftradius   =  pi/2,
    anglerightleg     =  3*pi/2 - pi/12,
    angleleftleg      =  3*pi/2 + pi/12),

  # 3. Walking (leaning forward, legs apart)
  pose_segments("Walking",
    x = 5, y = 7.4, scale = sc, ratioxy = rxy,
    angleofneck       = -pi/2 + pi/10,
    angleofspine      = -pi/2 + pi/10,
    anglerighthumerus =  pi/2 + pi/6,
    anglerightradius  =  pi/2,
    anglelefthumerus  = -pi/6,
    angleleftradius   =  0,
    anglerightleg     =  3*pi/2 - pi/5,
    angleleftleg      =  3*pi/2 + pi/4),

  # 4. Pointing right
  pose_segments("Pointing",
    x = 5, y = 7.2, scale = sc, ratioxy = rxy,
    angleofneck       = -pi/2,
    angleofspine      = -pi/2,
    anglerighthumerus =  0,
    anglerightradius  =  0,
    anglelefthumerus  = -pi/2 - pi/6,
    angleleftradius   = -pi/6,
    anglerightleg     =  3*pi/2 - pi/12,
    angleleftleg      =  3*pi/2 + pi/12),

  # 5. Sitting (spine horizontal, legs bent)
  pose_segments("Sitting",
    x = 5, y = 5.8, scale = sc, ratioxy = rxy,
    angleofneck       = -pi/2,
    angleofspine      =  0,
    anglerighthumerus = -pi/2 - pi/6,
    anglerightradius  = -pi/2,
    anglelefthumerus  = -pi/2 + pi/6,
    angleleftradius   = -pi/2,
    anglerightleg     =  pi/2,
    angleleftleg      =  pi/2 + pi/2),

  # 6. Falling / off-balance
  pose_segments("Falling",
    x = 5, y = 7.0, scale = sc, ratioxy = rxy,
    angleofneck       = -pi/2 - pi/4,
    angleofspine      = -pi/2 - pi/4,
    anglerighthumerus =  pi/4,
    anglerightradius  =  pi/2,
    anglelefthumerus  =  pi - pi/8,
    angleleftradius   =  pi/2 + pi/4,
    anglerightleg     =  3*pi/2 + pi/6,
    angleleftleg      =  3*pi/2 - pi/3)
)

# Combine into one data frame
all_segs <- do.call(rbind, lapply(poses, `[[`, "segs"))
all_heads <- do.call(rbind, lapply(poses, `[[`, "head"))

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

all_segs$colour  <- part_colours[all_segs$part]
all_heads$colour <- part_colours["head"]

# Fix facet order
pose_order <- c("Standing","Arms up","Walking","Pointing","Sitting","Falling")
all_segs$pose  <- factor(all_segs$pose,  levels = pose_order)
all_heads$pose <- factor(all_heads$pose, levels = pose_order)

set.seed(42)

p_poses <- ggplot() +
  # draw each bone segment
  geom_xkcdpath(
    aes(x = x, y = y, xend = xend, yend = yend, colour = colour),
    data = all_segs,
    linewidth = 1.3, mask = FALSE,
    xjitteramount = 0.04, yjitteramount = 0.04,
    inherit.aes = FALSE
  ) +
  # draw each head circle
  geom_xkcdpath(
    aes(x = x, y = y, diameter = diameter, colour = colour),
    data = all_heads,
    linewidth = 1.3, ratioxy = 1, mask = FALSE,
    inherit.aes = FALSE
  ) +
  scale_colour_identity(
    guide  = "legend",
    name   = "Body part",
    breaks = part_colours[c("head","spine","rarm1","larm1","rleg","lleg")],
    labels = c("head / scale","spine","right arm","left arm","right leg","left leg")
  ) +
  facet_wrap(~ pose, ncol = 3) +
  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 = 13)
  ) +
  labs(title = "xkcdman pose gallery")

p_poses


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.

# 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!