Digital resources in the Social Sciences and Humanities OpenEdition Our platforms OpenEdition Books OpenEdition Journals Hypotheses Calenda Libraries OpenEdition Freemium Follow us

Building a Shakespeare character network

Ten years ago I wrote a small Shiny app that drew a network of Macbeth’s characters. I showed it to colleagues working on Shakespeare, they liked it, I posted it, and then I left it alone. It sat on my hard drive doing nothing, like a houseplant you forget to water but which somehow refuses to die. I have now rewritten it completely. This post explains why, what the new version does, how the code works, and how to use it. Along the way I will say some things about co-occurrence networks, reactive programming, and graph metrics that I hope will be useful even to readers who have no intention of touching the app itself.

Why character networks at all?

The question is fair. Shakespeare scholarship is not exactly an underdeveloped field. What does a network diagram add to several centuries of close reading?

Quite a lot, it turns out. Not because the network replaces interpretation, but because it makes certain structural facts visible instantly that would otherwise take considerable effort and space to describe in prose. Who is the most dramatically central character? Which characters operate in isolated clusters and which bridge multiple social worlds? How does the social fabric of the play change as the story progresses? A network does not answer these questions for you, but it frames them in a way that is hard to achieve with a concordance.

The specific lens here is co-occurrence: two characters are connected if they appear in the same scene. The underlying assumption is that shared scenes imply dramatic interaction, and that the cumulative pattern of those interactions encodes something real about the social structure of the play.

This approach goes back at least to the network analyses of dramatic texts in the digital humanities literature. My own starting point, in 2015, was a blog post by data scientist David Robinson, who applied similar logic to the movie Love Actually. His code was both simple and elegant. I borrowed the core idea, wrapped it in a browser interface, and applied it to a single Shakespeare tragedy.

The new version borrows the same idea but extends it and, importantly, is no longer tied to a single hand-edited text file of one play.

What needed improvement in the original

Three things, in ascending order of embarrassment. First, the corpus was hard-coded. The app always showed Macbeth. If you wanted Hamlet, you needed to find your own plain-text file, format it to match my idiosyncratic conventions, and edit the source code. This is not a convenient workflow. Neither is it scalable.

Second, the network renderer was networkD3::simpleNetwork, which produces a pleasant-looking force-directed graph but gives you essentially no control over it and no information from it. Nodes were identical dots. Edges were identical lines. You could see that characters were connected, but not how strongly, not who the most structurally important characters were.

Third, and most embarrassingly: by Act V of Macbeth, almost everyone has shared a scene with almost everyone else, so nearly every pair of nodes has an edge. The resulting graph was, to use the technical term, an ugly hairball. I knew this and shipped it anyway. In 2015 I had lower standards because it was a toy project. It still is, although I am serious about how useful network science can be, as evidenced by these posts that I wrote:

What the new version does differently

Live text retrieval

The new app fetches plays directly from Project Gutenberg using the gutenbergr package. This means the corpus is not a file on your computer but a live query to a server, and the app includes fifteen plays in its dropdown (tragedies, comedies, and histories) without you needing to manage a single text file.

library(gutenbergr)
raw <- gutenberg_download(1533)$text  # 1533 is the Gutenberg ID for Macbeth

gutenberg_download() returns a tibble with a text column, one row per line of the original file. That is all you need to get started.

Automatic package installation

The app checks which of its dependencies are missing from your library and installs only those, silently, before loading anything:

required_packages <- c(
  "shiny", "bslib", "dplyr", "tidyr", "stringr",
  "ggplot2", "reshape2", "scales", "visNetwork",
  "gutenbergr", "igraph"
)
missing_packages <- required_packages[
  !required_packages %in% rownames(installed.packages())
]
if (length(missing_packages) > 0) {
  install.packages(missing_packages)
}

installed.packages() returns a matrix of everything currently in your R library. Comparing the required list against its row names gives you the missing subset. If it is empty, which it will be on any subsequent run, install.packages() is never called and nothing happens. The check itself costs a few milliseconds.

Which editions, and why

All fifteen plays are drawn from the same corpus on Project Gutenberg, prepared by the PG Shakespeare Team, a volunteer group of around twenty editors who produced a consistent set of plain-text Shakespeare editions in 1998, most recently updated in September 2025 (thanks guys!). The IDs used are listed in the table in the repository README. They all follow the pattern gutenberg.org/cache/epub/{ID}/pg{ID}.txt. Hamlet is the one exception in the credits: it was prepared by Dianne Bean rather than the team, but it follows the same formatting conventions throughout.

The reason for choosing this particular corpus rather than, say, the Folger Digital Texts or the First Folio transcriptions on Wikisource is blunt: the PG Shakespeare Team editions are consistent. Speaker names are in ALL CAPS throughout, scene headers follow a predictable pattern, and stage directions are in square brackets. This makes regex-based parsing feasible. More carefully edited scholarly editions (which distinguish between quarto and folio readings, mark emendations, or use mixed-case speaker labels) would require a substantially more complex parser and would still not necessarily produce cleaner results for the purposes of co-occurrence analysis, which is insensitive to textual variants at the word level.

The corpus is not without its idiosyncrasies, and some of them gave the parser a thorough workout. Macbeth opens with a global setting note (SCENE: In the end of the Fourth Act, in England; through the rest of the Play, in Scotland) that uses the word SCENE in a non-structural sense. Henry V begins with a PROLOGUE before ACT I, spoken by a CHORUS character. The Taming of the Shrew has an INDUCTION with two scenes before the first act proper. Each of these required a specific parsing decision, and the code comments document what those decisions were and why. The short version: anchor to the Gutenberg *** START *** marker, skip to the second occurrence of a standalone ACT I header, and trust that everything between the first and second occurrence is front-matter that can be safely discarded.

A proper parser (and a bug that taught me to test on more than one play)

Gutenberg plain-text Shakespeare plays follow a consistent convention: speaker names appear in ALL CAPS at the start of a line, followed by a full stop or a colon, followed by the dialogue. Stage directions appear in square brackets. Scene markers begin with SCENE followed by a Roman numeral.

The parse_play() function exploits this structure:

parse_play <- function(raw_lines) {
  tibble(raw = raw_lines) %>%
    mutate(
      is_scene = str_detect(raw, regex(
        "^\\s*scene\\s+[ivxlc0-9]+", ignore_case = TRUE
      )),
      scene = cumsum(is_scene)
    ) %>%
    filter(!is_scene, raw != "", !str_detect(raw, "^\\s*\\[")) %>%
    mutate(
      speaker_raw = str_match(raw, "^([A-Z][A-Z ]{1,30}[A-Z])[.:]")[, 2],
      dialogue    = str_replace(raw, "^[A-Z][A-Z ]{1,30}[A-Z][.:]\\s*", "")
    ) %>%
    fill(speaker_raw, .direction = "down") %>%
    ...
}

A few things worth noting. cumsum(is_scene) is a clean idiom for assigning scene numbers: it starts at zero and increments every time a scene header is detected, so every subsequent line inherits the correct scene number without any explicit loop. The fill(..., .direction = "down") call handles continuation lines — when a speech runs over multiple lines, only the first carries the speaker label, and fill() propagates it downward until the next speaker appears. The regex ^([A-Z][A-Z ]{1,30}[A-Z])[.:] matches between two and thirty-two capital letters (with internal spaces allowed), which is permissive enough to catch FIRST WITCH and LADY MACBETH while being restrictive enough not to match ordinary sentences that happen to start with a capital.

The final step filters out stage-direction tokens that Gutenberg sometimes renders as speakers (EXEUNT, EXIT, ENTER, ALL, BOTH, and so on). These are not characters. They are instructions to actors, and their inclusion would produce a node labelled EXEUNT connected to everyone, which would be unhelpful and faintly surreal.

I should admit that the scene-detection regex required a big fix after initial deployment. The original version used ^(ACT|SCENE|Act|Scene)\\s+[IVXLC0-9\\.]+ with case-sensitive matching and a strict start-of-line anchor. This worked for Macbeth (Gutenberg ID 1533), which uses clean SCENE I. headers on their own line. Hamlet (ID 1524) uses a different format: scene headers are sometimes indented, and act markers (ACT I, ACT II…) appear without accompanying scene lines that the regex could catch. The result was that Hamlet appeared to have exactly five scenes, one per act. A user will recognise this as a rather bold reinterpretation of the text.

The fix is the regex you see above: ^\\s*scene\\s+[ivxlc0-9]+ with ignore_case = TRUE. The ^\\s* tolerates leading whitespace. Matching only scene (not act) means act-only lines no longer count as scene boundaries, and case-insensitivity handles the variation between editions. Hamlet now correctly reports twenty scenes, which is both more accurate.

The co-occurrence matrix

Once the text is parsed into a tidy tibble of speaker–scene pairs, the network is built from a co-occurrence matrix. The logic lives in build_cooccurrences():

build_cooccurrences <- function(lines) {
  by_speaker_scene <- lines %>% count(scene, speaker)

  mat <- by_speaker_scene %>%
    acast(speaker ~ scene, fun.aggregate = length, fill = 0)

acast() from reshape2 pivots the data into a speaker-by-scene matrix, where each cell contains the number of lines spoken by that character in that scene. A cell value above zero means the character appeared. We then compute:

  co <- sub %*% t(sub)

This is the key step. Multiplying a speaker-by-scene matrix by its own transpose gives a speaker-by-speaker matrix where each cell [i, j] counts the number of scenes that characters i and j shared. This is the co-occurrence count that becomes the edge weight in the network. It is a compact operation that looks like linear algebra but is really just counting. Indeed, the dot product of two binary vectors counts the positions where both are non-zero, which is precisely the number of scenes where both characters appear.

The function wraps this in a lapply() over every prefix of the scene sequence, so it pre-computes the cumulative network at every point in the play. The app then indexes into this list as the scene slider moves, which keeps the UI responsive: the heavy computation happens once on load, not once per slider tick.

Graph metrics

The hairball problem is where the original app fell down most visibly. The new version renders the network with visNetwork, which uses a physics simulation to position nodes, and encodes two graph-theoretic quantities visually.

Degree is the number of edges connected to a node: how many other characters this character has shared a scene with. Nodes are sized proportionally to their degree, so heavily connected characters appear larger.

Betweenness centrality is, admittedly, more interesting. For every pair of nodes in the graph, you can find the shortest path between them. Betweenness counts, for each node, how many of those shortest paths pass through it. A character with high betweenness is a broker. They sit between otherwise disconnected parts of the social network. In Hamlet, Horatio has high betweenness because he is the only character who moves freely between the court world and the world of the soldiers and scholars. In A Midsummer Night’s Dream, Puck has high betweenness because he is the only character who crosses between the Athenian lovers and the fairy court. This is not a coincidence. Betweenness tends to identify the characters whose structural position mirrors their dramatic function. Nodes are coloured on a ramp from blue (low betweenness) through orange to red (high betweenness), computed by igraph::betweenness() with normalisation.

As for the hairball: the primary fix is an edge filter. A slider controls the minimum number of shared scenes required for an edge to appear. At the default value of 1, you see every connection. Raise it to 3 and you see only pairs who have genuinely shared multiple scenes, which in practice means meaningful dramatic relationships rather than incidental proximity. By the end of Othello, setting the filter to 2 or 3 reduces a near-complete graph to a sparse, legible structure with Othello and Iago clearly at its centre.

The secondary fix is the physics layout itself. visNetwork uses a Barnes-Hut algorithm. It is a space-partitioning approximation of the n-body problem, originally developed in astrophysics for galaxy simulation and now repurposed for drawing graphs, which is the kind of trajectory a useful algorithm has. The repulsion constant is set high enough (gravitationalConstant = -8000) that loosely connected nodes are pushed to the periphery, while the spring constant keeps strongly connected pairs close. With randomSeed = 42, the layout is reproducible between sessions.

Running the app

Download app.R from the GitHub repository and run:

shiny::runApp("Shakespeare_network.R")

Missing packages will install themselves. This should not take more than a minute or two the first time and nothing subsequently.

A guided tour

When the app opens you will see a sidebar on the left and two panels on the right.

The landing page, with Macbeth loaded by default

Start with Macbeth, which is the default, and wait a few seconds for the download and parse to complete. A progress bar tells you what is happening.

The progress bar when a play is loading

The ‘Character co-occurrence network’ panel shows the co-occurrence graph. At scene 1, with only the three witches on stage, you will see three nodes of similar size connected in a triangle.

“When shall we three meet again? In thunder, lightning, or in rain?”

Move the scene slider forward and watch the graph grow. By scene 5 or 6, King Duncan, who kicks off Shakespeare’s Macbeth as the gold-standard king (fair, trusting, and quick to reward loyalty) is the largest node: he has the most connections.

King Duncan as the most connected character by scene 6

By scene 10 you can see Macbeth and Lady Macbeth clustered together, and the three witches forming a separate cluster connected to the main body primarily through Macbeth, which is both graphically clear and dramatically accurate.

Macbeth has become the central character by scene 10

By scene 25, without filtering, the graph is becoming unpleasant due to the maze of edges between nodes.

Scene 25, minimal co-appearances set to 1: hairball!

Move the minimum co-appearances slider to 3. The peripheral characters drop away, and what remains is the spine of the play: Macbeth at the centre, Lady Macbeth and Banquo as the next tier, Malcolm and Macduff arriving later and pulling the graph toward its resolution.

Scene 25, minimal co-appearances set to 3: much clearer!

You can hover over any node to see its exact degree and betweenness score. You can click a node to highlight its neighbourhood and grey out everything else.

The ‘Character timeline’ panel below shows every character as a row, with dots at each scene where they speak, sized by the number of lines they deliver. The y-axis ordering is not alphabetical. It is determined by hierarchical clustering on each character’s scene profile, using Canberra distance on normalised appearance vectors, which tends to group characters who appear in similar scenes adjacently. The red dashed line tracks the current scene slider position. Characters who have appeared by the current scene are coloured blue. Those who have not yet appeared are grey. Watching the greys turn blue as you move the slider is a compact way of seeing the dramatic rhythm of the play: which characters belong to which movements, where the deaths cluster, where new figures enter, etc.

The ‘Character timeline’ panel (set at scene 10)

Switch to A Midsummer Night’s Dream. It is structurally unusual. You have three basically separate storylines running in parallel: the Athenian lovers doing their chaotic romantic disaster thing, the mechanicals bumbling through rehearsals, and the fairies being supernatural and terrible to everyone. They collide in the forest, which is doing a lot of heavy lifting as a plot device, and then get neatly resolved at the end (Shakespeare is so good at this).

Character co-occurrence for A Midsummer Night’s dream at scene 9 with minimal co-appearances set to 3

The network graph reflects the structure somehow, but you have to filter it correctly or it just looks like a hairball. Crank up the edge threshold and things get interesting. What you see is a dense core of lovers and fairy royals (Lysander, Demetrius, Helena, Hermia, Titania, Oberon, Puck) all tangled up together, which makes sense because the forest scenes are where everything overlaps (Act 2 scenes 1 and 2, Act 3 scenes 2 and 3, Act 4 scenes 2 and 3). Then off to the right, slightly detached, you have the mechanicals: Quince and Starveling clearly peripheral, connected to the core through exactly one node. That node is Bottom, the only mechanical who actually enters the fairy plot, becomes part of it for a while, and serves as the structural bridge between the two worlds.

Character timeline for A Midsummer Night’s dream

Try Julius Caesar and set the scene slider to 10, just past Caesar’s assassination. Caesar’s node is still present because co-occurrence is cumulative, and he appeared in many of the early scenes. But Brutus, Cassius, and Antony are now larger and more central, which corresponds precisely to the dramatic fact that the play shifts its weight from Caesar to the men who killed him and the man who avenges him.

Julius Caesar at scene 10

Standing on the shoulders of geeks who also like Shakespeare

I am not the first to have pointed a network analysis tool at Shakespeare’s plays, and it would be ungracious to pretend otherwise. Two pieces of prior work deserve particular mention.

Martin Grandjean’s Network visualization: mapping Shakespeare’s tragedies (2015) is the most visually compelling treatment of the question I have seen. Working from the eleven Shakespearean tragedies as a corpus, Grandjean represents each character as a node connected to characters appearing in the same scenes, and the resulting static visualisations are genuinely beautiful, the kind of thing you can project in a lecture and have the room immediately understand that something structurally interesting is being said. The comparative aspect is particularly illuminating: Hamlet, the longest tragedy, turns out not to be the most structurally complex because it is less dense than King Lear, Titus Andronicus, or Othello. Some plays reveal their dramatic groupings immediately: Montagues and Capulets in Romeo and Juliet, Trojans and Greeks in Troilus and Cressida. If the question is “what does the whole network of a play look like?”, Grandjean’s work answers it with more elegance than mine.

Bastian Rieck’s Towards Shakespearean Social Network Analysis (2016), building on Ingo Kleiber’s corpus and code, takes a more analytical direction. Using an adjacency matrix where edge weights indicate how often characters appear in the same scene, Rieck scales and colours nodes by degree, then extends the analysis to graph density across all plays to reveal that historical plays tend to have many characters but low density, while comedies have fewer characters but substantially higher density. This cross-genre structural comparison is something a static visualisation handles better than an interactive app as you need to see all thirty-seven plays at once, not one at a time.

What the present app does differently is trade breadth for depth and stasis for motion. Rather than a comparative snapshot across plays, it offers a dynamic view of a single play unfolding scene by scene, which is, I would argue, closer to the experience of actually watching the thing. The cumulative network at scene 10 of Macbeth is not the same object as the cumulative network at scene 25, and the difference between them is where the drama lives.

A note for colleagues and students in literature

I am aware that not everyone in literary studies finds this kind of thing immediately appealing. The objection is usually in the shape of: “You have reduced four centuries of interpretive tradition to a dot-and-line diagram.” This is fair. I have. And a dot-and-line diagram cannot tell you that Macbeth’s compulsive return to the witches is a study in how prophecy functions as a form of self-authorised inevitability, or that Iago’s motivations remain productively opaque in a way that makes each production a fresh argument. These are readings. The network does not do readings.

What the network does do is make structural facts legible at a glance that would otherwise require laborious description. Saying that Macbeth is the most central character in his own play sounds trivial until you see that centrality quantified and see, by contrast, how decentered a play like The Tempest is, with Prospero occupying a brokerage position that connects otherwise isolated clusters. The graph does not interpret that difference. It displays it in a form that makes interpretation easier to anchor and compare.

For teaching purposes, this turns out to matter. Students who know nothing about graph theory will spend five minutes with the slider and have formed an intuition about co-occurrence, structural centrality, and dramatic clustering that would take considerably longer to develop from prose description alone. Whether that intuition then leads somewhere interesting is, as always, up to them. The app cannot do their thinking for them. It can, however, give them something concrete to push against, which is often how thinking starts.

Co-occurrence is not dialogue

A word on what this kind of analysis is not. Two characters can share a scene without exchanging a word. Think for instance of the silent attendants who fill out Shakespeare’s court scenes. A more rigorous analysis would use direct address, or named references in speech, or actual turns in conversation. These approaches require either careful annotation or a more sophisticated parser than a regex operating on plain text can provide.

Co-occurrence is also not causation, influence, or affection. A high betweenness score means structural brokerage but it says nothing about whether that brokerage is benign or malevolent. Iago has high betweenness in Othello, and so does Desdemona. The graph cannot tell them apart.

What co-occurrence is is a useful, fast, and honest first pass. It is the kind of analysis that takes thirty seconds to generate and much longer to discuss productively in a seminar, which is not nothing.

Licence and code

The app is shared under a CC BY-NC-ND 4.0 licence. The full code, including the companion files for a GitHub repository, is available at https://github.com/GuillaumeDesa/shakespeare-character-network.

If you use it in teaching or research, I would be glad to hear about it. And if the parser produces bizarre results on a play I have not fully tested (which is entirely possible, Gutenberg editions are not always consistent) please open an issue on Github and I will look at it.

May your networks be sparse and your betweenness scores interpretable!

Guillaume Desagulier
Guillaume Desagulier
Université Bordeaux-Montaigne, Laboratoire CLIMAS, Institut Universitaire de France

The text only may be used under licence Creative Commons Attribution Non Commercial 4.0 International. All other elements (illustrations, imported files) are “All rights reserved”, unless otherwise stated.


OpenEdition suggests that you cite this post as follows:
Guillaume Desagulier (May 20, 2026). Building a Shakespeare character network. Around the word. Retrieved July 25, 2026 from https://doi.org/10.58079/168vu


You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.