EPIC: The Musical vs Nolan's The Odyssey - One Story, Visualised as a Graph

How does Christopher Nolan's film compare to EPIC: The Musical? I mapped every plot thread and character of Homer's poem in a graph database to find out.

Christopher Nolan's The Odyssey is stacked: Matt Damon, Anne Hathaway, Zendaya, Tom Holland. But Nolan is late to this party - Jorge Rivera-Herrans got there first with EPIC: The Musical. And behind them both stands Homer, who has waited 2,800 years for someone to ask: what does one story look like when three tellings of it live in a single graph database?

So I built one. 130 nodes, 235 relationships, one Neo4j Aura database. Who blinds whom? Who curses whom? And which two very different men have both played Odysseus?

Why a graph?

At its core, The Odyssey is a story about connections. Divine grudges, family loyalties, and why you should never give a Cyclops your name. Poseidon doesn't hunt Odysseus because of who he is - he hunts him because Polyphemus is his son. Put those connections in a spreadsheet and you get a table of names and a lot of squinting. Put them in a graph, and the plot becomes structure:

text
(Odysseus)-[:BLINDS]->(Polyphemus)
(Polyphemus)-[:CURSES]->(Odysseus)
(Polyphemus)-[:CHILD_OF]->(Poseidon)
(Poseidon)-[:OPPOSES]->(Odysseus)

In the data (relationships.csv), each of those is literally just a row:

fromtotypedetail
OdysseusPolyphemusBLINDSThe wooden spike; Remember Them
PolyphemusOdysseusCURSESPrays to Poseidon for revenge
PoseidonOdysseusOPPOSESTen years of vengeance for the blinding of his son

And because the data is shaped like sentences (NOUN -> VERB -> NOUN), asking it questions in Cypher looks like this:

cypher
MATCH (a {name: "Poseidon"})-[r:OPPOSES]->(b)
RETURN a, r, b

Which is just Cypher speak for: "show me everyone Poseidon has beef with."

What we're building

By the end of this post you will have:

  • A knowledge graph of the Odyssey across all three tellings of the story, running in Neo4j Aura
  • A dataset where every single relationship carries its own source
  • Queries that answer questions like: what did EPIC cut from Homer? Who has played Odysseus? What is the shortest path from Troy to home?

Let's get started.

Choose your own adventure

If you're only here for the EPIC: The Musical and Christopher Nolan fandom questions, jump straight to Asking the data questions, where the graph answers:

If you're here to see how I built the graph with Cypher, carry straight on to:

Skip whichever half you like. Unlike Poseidon, I don't hold grudges.

Where the data came from

Unfortunately, there is no Odyssey API. So the data for this graph is Frankenstein-stitched together from four different sources:

  • Myth data Homer's poem in Emily Wilson's translation (the one Nolan cites as an influence), plus Theoi.com for lineages.

  • EPIC: The Musical The official track list: nine sagas, forty songs, song-to-character connections verified by me.

  • Film details Wikipedia and Rotten Tomatoes. (IMDb says Charlize Theron plays Circe; the other two say Calypso. I went with the majority.)

  • Cast details Wikipedia, IMDb, and BroadwayWorld.

All of it lives in exactly two CSV files: nodes.csv and relationships.csv.

nodes.csv answers: "what exists in this story?" These are the NOUNS - people, deities, monsters, locations, songs, one very good dog.

relationships.csv answers: "how is it all connected?" These are the VERBS - who betrayed whom, who married whom, who blinded Poseidon's son and started this whole saga in the first place. You know, the juicy stuff.

Here's a taste of nodes.csv:

nametypedescriptionnotes
OdysseusHeroKing of Ithaca; hero of the Trojan War trying to get homeGuitar motif in EPIC
PoseidonDeityGod of the sea; Polyphemus's father; Odysseus's great antagonistTrumpet/brass motif in EPIC
PolyphemusMonsterCyclops son of Poseidon; blinded by Odysseus
ArgosAnimalOdysseus's loyal dog; recognises him after 20 yearsFeatured prominently in the Nolan film
The Troy SagaSagaLeaving Troy; the infant; the lotus eaters; meeting Athena
Note

This two-file structure isn't the only way to organise data for Neo4j. You can also use the visual Import tool, write nodes and relationships directly in Cypher, or load data through a driver from Python or JavaScript (Importing Data Fundamentals covers the options).

Importing data into Neo4j Aura

First, I created an instance. An instance is just your own database, running on Neo4j's computers instead of yours. You click "create," wait a minute, and have a place to put your data. A bit like opening a new Google Doc instead of installing Word. Next, before pulling any data in, I wrote this Cypher query:

cypher
CREATE CONSTRAINT entity_name IF NOT EXISTS
FOR (n:Entity) REQUIRE n.name IS UNIQUE

CREATE CONSTRAINT tells the database to set a rule. It helps to read it out loud: "for any node labelled Entity, its name must be unique." Basically, no two nodes can share a name. IF NOT EXISTS just means that if I accidentally re-run this query, it won't break everything and set my data on fire.

For the import itself, I put both CSVs in a GitHub repository and loaded them with Cypher queries, typed into the Query tab in Aura's sidebar. I chose this route because the query format felt familiar from loading data in Python.

To pull my node data in from GitHub, I used:

cypher
LOAD CSV WITH HEADERS FROM 'https://raw.githubusercontent.com/Kemi-Elizabeth/odyssey_graph/main/nodes.csv' AS row
MERGE (n:Entity {name: row.name})
SET n:$(row.type),
    n.description = row.description,
    n.notes = row.notes,
    n.source = row.source

Here's what each line is doing:

  • LOAD CSV WITH HEADERS FROM '…' AS row - "open the file at this URL, treat the first line as column names, and hand me the rows one at a time as row."
  • MERGE (n:Entity {name: row.name}) - "find a node with this row's name, or create it if it doesn't exist yet." (n is a nickname for the node; Entity is its label.)
  • SET n:$(row.type) - $( ) fills in the blank with the 'type' data I've already specified in my CSV: Odysseus's row becomes SET n:Hero, Polyphemus's becomes SET n:Monster and so forth.
  • The remaining SET lines attach the other columns - description, notes, source - as properties. They're like sticky notes on the node that don't change the graph's shape.

Here's that query running in the Query tab:

The LOAD CSV query in Aura's Query tab, with its success banner: created 130 nodes, set 520 properties, added 260 labels

Then, to add the relationships.csv:

cypher
LOAD CSV WITH HEADERS FROM 'https://raw.githubusercontent.com/Kemi-Elizabeth/odyssey_graph/main/relationships.csv' AS row
MATCH (a:Entity {name: row.from})
MATCH (b:Entity {name: row.to})
MERGE (a)-[r:$(row.type)]->(b)
SET r.detail = row.detail,
    r.source = row.source

The Cypher code for this follows the same pattern as above, except for three things:

  • The two MATCH lines - MATCH is "find, but never create." The nodes are already in the database, so these lines find them and nickname them a and b. (This is why the nodes had to load first: you can't draw an arrow between circles that don't exist.)
  • MERGE (a)-[r:$(row.type)]->(b) - (a)-[…]->(b) draws an arrow from a to b, and $(row.type) names the type of relationship I've defined in the CSV. For example, (a)-[r:$(row.type)]->(b) could be (Odysseus)-[:BLINDS]->(Polyphemus), where (a) is Odysseus, (b) is Polyphemus and [r:$(row.type)] is [:BLINDS]. The Cypher sentence literally turns into an arrow of who did what to whom.
  • The remaining SET lines put sticky-note properties on the arrow itself this time.

And if you speak Python…

If you've done any data work in Python, this whole pattern should feel like home. Here's how the same import could look in pandas:

python
import pandas as pd
 
rows = pd.read_csv("https://raw.githubusercontent.com/.../nodes.csv")
for _, row in rows.iterrows():
    add_node(name=row["name"], label=row["type"], description=row["description"])

Where did my five nodes go?

After loading the data, I verified the count with the following Cypher query:

cypher
MATCH (n) OPTIONAL MATCH (n)-[r]->()
RETURN count(DISTINCT n) AS nodes, count(r) AS relationships

If you read the query aloud, it says: "count every node, and every outgoing arrow." (OPTIONAL MATCH includes unconnected nodes in the count because without it they'd vanish.)

Now, I knew that my CSVs contain 130 nodes and 235 relationships. However, my database disagreed, telling me I had 125 nodes instead:

nodesrelationships
125235

This was incorrect, and also meant that five of my data points (nodes) were missing. So it was time to play detective and figure out where they went:

cypher
MATCH (n) WHERE size(labels(n)) > 1
RETURN n.name, labels(n), n.description

This Cypher query means: "show me every node that has more than one label." And there they were:

Query results showing five nodes with two labels each: Odysseus as Hero and Song, Polyphemus, Scylla and Charybdis as Monster and Song, and The Underworld as Place and Song

Odysseus was incorrectly labelled as both a Hero and a Song. Scylla was also labelled as both a Monster and a Song, and so forth. I had five nodes that had encountered an identity collision.

The mistake I made

In EPIC: The Musical, five of the songs are named after the character or place they're about. So when LOAD CSV reached the song "Odysseus," MERGE found the character, added the Song label to him, and overwrote his description with the song's. The database obeyed my MERGE rule - I just hadn't noticed that five nodes in my data shared names with other nodes.

To fix this, I renamed the five colliding songs in the CSVs - e.g. Odysseus (song), Scylla (song) - updated their rows in relationships.csv, and rebuilt from scratch:

cypher
MATCH (n) DETACH DELETE n

This means: "find every node; delete it and any connections attached to it."

Now I could re-run the two LOAD CSV queries from above and recount to be sure that my data was correct, with 130 nodes:

nodesrelationships
130235
The lesson

Deciding what makes a node unique is a significant data modelling decision. If you get it wrong, you'll be fixing duplicates at 1am while drinking your sixth cup of tea. Be better than me.

Removing labels from data

Remember back when I was first importing the data, I used CREATE CONSTRAINT to set the rule ("no two Entity nodes may share a name") and MERGE (n:Entity {name: …}) afterwards while loading every row in. The Entity label existed purely so that the CREATE CONSTRAINT rule had something to attach to while the data was being ingested. It was structural and temporary.

The reason I now have to remove it is that our visualisation tool in Aura (Bloom) colour-codes by label, and if every node shares the Entity label, they will all be the same colour. No one wants to see one hundred and thirty identical beige dots, so let's remove it:

cypher
DROP CONSTRAINT entity_name

This is Cypher speak for "forget that entity_name rule I set earlier."

cypher
MATCH (n) REMOVE n:Entity

This query peels the Entity label off every node, revealing the real labels I defined in the CSV - Hero, Monster, Song. Now they get to shine with their own colours in the visualisation.

Customizing a data visualisation with Bloom

Everything visual in this post comes from Neo4j's visualisation tool, Bloom, which lives in the Aura console sidebar. (You may also know it as Explore - it's the same tool, and it has carried both names at different points.) It costs nothing extra and requires no libraries or code. Here's how I used it to go from grey dots to a colour-coded constellation.

A Bloom visualisation is made up of two concepts:

  • A Scene: your current canvas of which nodes you've decided to visualise. Think of it like an arrangement of magnets on your fridge.
  • A Perspective: the colours, icons, sizes, and captions that define how each kind of node looks. Think of this like the design of the fridge magnets themselves.
Tip

Your data visualisation styling lives in the Perspective, but only if you save it there. After any styling session, click "Apply styling to Perspective" (bottom-right of the canvas) or you'll be designing your nodes all over again next time. I learned this the hard way.

Getting the graph on canvas

As I had already loaded my data and verified it in the Query tab, my data was already in Aura. So now I could do things like:

  • Search for nodes. Type a label like Hero or Song or a name like Poseidon in the search bar and hit enter.
  • Reveal the arrows. Select everything (Cmd/Ctrl+A), then right-click on any node, and click Expand All. (Right-click a node, not the empty canvas - I made this mistake and was very confused for a very long time.) Each expand pulls in one more hop of connections, so a few rounds of select-and-expand blooms the whole graph out of a single search:

Searching for Hero and repeatedly expanding: three lonely nodes bloom outwards in waves until all 130 nodes of the story are on the canvas

The full graph in Bloom: 130 colour-coded nodes showing the story's characters, places, songs, and actors - with one small cluster floating disconnected on the right

Why is Agamemnon all alone over there?

The first time the full data visualisation was created, I noticed a small cluster of nodes floating off to one side, connected to each other but absolutely nothing else: Agamemnon, Menelaus, Helen of Troy, Clytemnestra, and the actors who play them (Lupita included).

The disconnected island: the Trojan War cluster of heroes, family, and their actors, linked to each other but not to the rest of the story

Every one of those nodes was in the CSVs, but nothing links them to Odysseus's side of the story. My spreadsheet had held that gap without me noticing; the graph showed it in one glance - and fixing it is just as quick. Homer even supplies the missing link: in Book 4, Telemachus visits Menelaus for news of his father. One query writes that visit into the graph:

cypher
MATCH (t:Mortal {name: "Telemachus"})
MATCH (m:Hero {name: "Menelaus"})
MERGE (t)-[:VISITS {detail: "Seeking news of his father; Homer Book 4"}]->(m)

Read aloud: "find Telemachus and Menelaus, then connect them - unless they're already connected." And here's the island after that one query, bridged to the mainland through Telemachus:

The island after the fix: a single VISITS relationship now connects Telemachus to Menelaus, linking the Trojan War cluster to the rest of the story

The whole fix took one query and about ten seconds - my CSVs never even noticed. I expected reshaping a data model to feel like surgery, but with Aura it was completely pain-free.

Using the Legend panel

All styling happens in the Legend panel on the right. Click a category's swatch and you can set the colour, size, icon, and caption of each node and relationship arrow. You can also choose which properties you want shown, or reveal them on hover to avoid crowding.

Styling a category in Bloom's Legend panel: opening the Actor category's swatch and picking colours, with the rule-based styling options alongside

Tip

The Export button captures exactly what's on your canvas, so if you zoom into any corner of the graph, it will export your current view as a crisp image instead of you having to screenshot it yourself.

Asking the data questions

First, I wanted to know how many connections Odysseus has in the story - all the characters, songs, places, monsters, and deities that directly connect to him in some way throughout The Odyssey. So this was the Cypher query I used:

cypher
MATCH p = ({name: "Odysseus"})--()
RETURN p

This means: "find Odysseus, and show me every connection he has, in any direction." The --() part means "any relationship to anything".

And here was the result:

Odysseus at the centre of his story: a starburst of nearly sixty direct connections to characters, gods, monsters, places, and songs

The query returns 61 connections and switching the result to Table view shows them exactly as the sentences they are:

The query results as a table: each row reads as a sentence, like Odysseus CHILD_OF Anticlea, Athena MENTORS Odysseus, and Odysseus BLINDS Polyphemus

Now we can see every connection of Odysseus's story: his wife, his son, his mentor, his monsters, the songs he's featured in, and the two different actors who have played him in the film and musical.

Who is playing who in the film adaptation?

I was curious about who is playing which character in Christopher Nolan's film adaptation, so I used the query below to find out:

cypher
MATCH p = (:Actor)-[r:PLAYS]->(character)
WHERE r.detail CONTAINS "film"
RETURN p

I used the WHERE line to add a filter in my query. This is because both the film cast and the musical cast use the same PLAYS relationship, but each arrow carries a detail property that tells us whether the actor belongs to the musical or the film. So here I'm filtering on a property of the relationship itself instead of the node. I'm basically saying: tell me which actor plays which character, but only in the "film".

The film cast in Bloom: fourteen actors, each pointing at the character they play in Nolan's The Odyssey

The same results in Table view read as a cast list:

The film cast as table rows: Matt Damon plays Odysseus, Anne Hathaway plays Penelope, Zendaya plays Athena, and Lupita Nyong'o appears twice - as Helen of Troy and Clytemnestra

For anyone who doesn't like writing queries: Bloom has a Query Builder feature that lets you build the same Cypher query visually. You just pick the category, add the condition and choose the relationship:

Bloom's Query Builder for graph pattern dialog, building the same film-cast question visually: category Actor, condition description contains film, relationship PLAYS

Who plays who in the musical adaptation?

Jorge Rivera-Herrans went to great lengths to involve his supporters in the production of his musical adaptation of The Odyssey, so I was curious to see which talented singers had taken on which roles. To ask this question of the data, I used the same query with one word changed:

cypher
MATCH p = (:Actor)-[r:PLAYS]->(character)
WHERE r.detail CONTAINS "musical"
RETURN p

The musical cast in Bloom: fourteen voice actors and the characters they play in EPIC: The Musical

The full cast list, straight from the query:

actorplays
Jorge Rivera-HerransOdysseus, Polyphemus
Anna LeaPenelope, Sirens
Miguel VelosoTelemachus
Teagan EarleyAthena
Steven RodriguezPoseidon
Luke HoltZeus, Elpenor
Armando JuliánEurylochus
Steven DookiePolites
Talya SindelCirce
Barbara WanguiCalypso
Troy DohertyHermes
Ayron AlexanderAntinous, Perimedes
KJ BurkhauserScylla
Kira StoneAeolus

Who has played Odysseus?

cypher
MATCH p = (:Actor)-[:PLAYS]->({name: "Odysseus"})
RETURN p

Matt Damon and Jorge Rivera-Herrans, the only two actors to have played Odysseus, both pointing at the same node

See how I didn't add a WHERE filter here. It's because I wanted to know who plays Odysseus in both the musical and the film - aka Matt Damon and Jorge Rivera-Herrans, a Hollywood A-lister and a man who cast a musical over TikTok.

What did EPIC cut from Homer?

While building the dataset, whenever something existed in the poem but not the musical, I noted it - "Homer only" - in the node's notes property. Which means the graph can now answer a question adaptation-obsessed fans argue about: what did the musical leave out of Homer's original poem?

cypher
MATCH (n) WHERE n.notes CONTAINS "Homer only"
RETURN n.name, n.description

The answer:

The query results: Laertes, Eurycleia, Ismarus, Laestrygonia, and Scheria - the characters and places from Homer's poem that EPIC: The Musical left out

What's really cool is that I can query the node properties as well as the nodes themselves, because I recorded them in the data. It's a nice thing to remember: whatever you include in your data can be queried, even if it doesn't appear as an actual node itself.

How did Odysseus get home?

In the tale of The Odyssey, Odysseus goes from Troy to Ithaca, but what does that journey look like, and how many stops does he make along the way? I asked the data with Cypher:

cypher
MATCH p = ({name: "Troy"})-[:TRAVELS_TO*]->({name: "Ithaca"})
RETURN p

The * means "keep following the TRAVELS_TO arrows, stop after stop, until we reach Ithaca". The result is the entire journey, with every stop included, shown as a single path:

The full journey from Troy to Ithaca: fourteen stops arcing from Troy on the left, through the Cyclops, Circe, the Underworld, and Calypso, to Ithaca on the right

So whose story is it, anyway?

The simplest question you can ask of a graph is a humble one: who touches the most of the story? To answer it, we count every character's connections and rank them:

cypher
MATCH (n)-[r]-()
WHERE NOT n:Song AND NOT n:Saga AND NOT n:Actor
RETURN n.name AS character, count(r) AS connections
ORDER BY connections DESC
LIMIT 10

The WHERE NOT line excludes songs, sagas, and actors from the ranking because we're only counting the characters in the story.

The verdict:

The top ten most connected characters: Odysseus leads with 61 connections, followed by Eurylochus with 15, then Telemachus, Penelope, and Athena with 11 each

Odysseus wins, obviously, with 61 connections. In second place: Eurylochus, the man who opened the wind bag, with more connections than even Penelope and Athena. And Poseidon, whose ten-year grudge is basically the whole plot, scrapes in with just six connections - because as we said at the start, he only has beef with Odysseus because of his son - the Cyclops.

What I learned about learning Cypher

I built this project to teach myself Cypher and Aura, and I would highly recommend learning a query language on data you already love. Every query had an answer I actually cared about, which meant every mistake was worth debugging - and, as you've seen, there were so many mistakes.

If you want to learn in the same way I did, these are the GraphAcademy courses that got me here, all free:

Away from the screen, I've also been reading "Neo4j: The Definitive Guide" by Luanne Misquitta & Christophe Willemsen, which helped some of the Cypher concepts make sense.

Click the button below to clone the dataset used in this post, load it into a free Aura instance, and ask the story your own questions.

Get the Odyssey dataset

Comments (0)

Loading comments...