Project story
Alethiometer
A browser-based recreation of the truth-telling instrument from Philip Pullman's His Dark Materials trilogy, and an excuse to see how far I could take my skills in drawing/animation, UX design, and framework-free frontend development, all without compromising on accessibility.

The idea
Bringing a fictional object into our world
I've always loved the idea of the Alethiometer from His Dark Materials. As a kid I would play around with the little plastic version that came with the Golden Compass boardgame. I found it was a joy to try and figure out what all of the symbols could mean in their deepest and most nuanced sense. It was in Grade 7 that I decided I wanted to learn how to code so that I could make a computerized version that actually worked. Now, all these years later, I was finally able to make that dream a reality.
When I sat down to recreate it, I wanted to do it right - it wasn't just making something that looked like the golden compass. I wanted it to behave like the instrument from the books: intricate, beautiful, and mysterious enough to inspire a user to actually get excited about learning how to read it for themselves.
The basic idea from the book series is that a reader (usually a trained scholar) is able to consult the Alethiometer with questions that they want to know the answers to. They compose their question by pointing the three movable arms to different representational symbols. Then, the mysterious forces of the universe are able to control the fourth arm, and provide an “answer” to the question by pointing to more symbols. It is up to the reader to determine what the symbols mean in the context of the question they have asked.
The architecture
Building it without a framework
I deliberately built the project with Vite and modern vanilla JavaScript. Part of the point was to get closer to the browser APIs themselves instead of letting a component framework organize state and interaction for me.
The project eventually settled into a modular ES-module architecture. main.js coordinates the application while separate modules own the question arms, answer arm, reading engine, side panel, and tutorial.
Challenge #1
A million ways to move an arm
I wanted to push my UX design skills with this project. As a user one of the most important actions you take is to move the question arms around the face of the Alethiometer. As such, I wanted to make sure it was always an easy action to do. This culminated in five different input options to move each of the three arms:
- Dragging the arms themselves
- Dragging the dials
- Scrolling a mouse wheel or trackpad while hovering over the Alethiometer face
- Using the keyboard
- Choosing a symbol directly from a select control
As is often the case, building in accessible input options improves usability for everyone! The keyboard and select-dropdown control made it easy to move the arms quickly to where I wanted them to go. By incorporating this decision into my designs early, I was able to reuse the same underlying state management logic across all input methods.
Pointer coordinates are converted into angles, angles are normalized, movement follows the shortest rotational path, and arms snap cleanly to symbol positions. The dials also have their own animation frames (just three frames I drew that slightly move the dials, which play forwards or backwards depending on which direction you drag) that needed to stay synchronized with the arms.
Challenge #2
Making randomness feel intentional
I had a university professor once tell me that no one will ever accept true random as being actually random. True random means a song could play three times in a row in a playlist, or it could mean winning every second round of Bingo. Our natural desire to seek out patterns always challenges the absurdity of true randomness. Instead, we need to strive to make our code feel nearly random.
While the user is deciding on their question, the Alethiometer’s fourth arm “randomly” moves around its face. In the novels this arm is described as idly swinging this way and that, unfocused and meandering. To try and accomplish this randomness, I built a system that modifies the rotation direction, the speed, and destination of the fourth arm in a way that feels nearly random.
Meanwhile, once a question has been asked, the Alethiometer begins its reading, and the fourth arm moves with intention. This was a balancing act as a programmer because I don’t, in fact, have the ability to harness the infinite wisdom of the universe, but I didn’t want it to feel random and empty to the user. I won’t give away all of the secrets, but I always made sure to leave the heart of the symbol choices up to chance, while also carefully manipulating the statistical likelihood of other symbols in turn. I kept a kernal of true randomness wherein each option is possible , but bounded by reasonable controls to ensure the responses feel intentional.
One of the most fun parts of the Alethiometer to me is trying to figure out what an answer actually means, so I didn't want the app to simply spit out an interpretation for you. Each symbol has layers of possible meanings, and the number of times the answer arm circles around a symbol is used to label that part of the reading as primary, secondary, or deep. The dictionary gives you somewhere to start, but the deeper meanings gradually fade away rather than ending in a hard stop - a little reminder that there is always another possible layer if you want to keep digging.
Challenge #3
36 icons, oh my!
One of the things that took more finessing than I expected was placing all of my hand-drawn (well, iPad-drawn!) 36 symbols around the face of the Alethiometer. Mathematically, each symbol is easy enough to place - they're spaced 10° apart around a circle. Visually, however, a tall narrow candle and a wide sun don't look centred just because their image boxes are centred. I ended up giving individual symbols small adjustments to their scale, rotation, and x/y positioning until the whole ring actually looked balanced.
I ran into a similar problem anywhere those same symbols appeared outside the Alethiometer face. Instead of manually resizing every preview, I use a canvas to inspect the visible pixels in each PNG, calculate the bounds of the actual drawing, and use that to automatically scale and nudge the artwork into place. I can then layer on a few symbol-specific tweaks for drawings that still need them.
With the same 36 symbols appearing on the Alethiometer face, in the controls, dictionary, readings, and previews, I also didn't want to repeat all of that information throughout the codebase. A shared constants.js file acts as the source of truth for each symbol's position, name, meanings, alt text, description, and image, so every part of the app is working from the same data.
Code notes
Small modules, clear jobs
constants.jsThe shared source of truth for symbol positions, meanings, descriptions, and image assets.Code excerpt
Each symbol is defined once, then its image is matched automatically from the assets folder.
export const SYMBOL_RING = SYMBOL_RING_BASE.map((symbol) => ({
...symbol,
iconUrl: (() => {
const key = String(symbol.name)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "")
const iconUrl = symbolIcons[key]
if (!iconUrl) {
throw new Error(`Missing symbol icon for key: ${key}`)
}
return iconUrl
})(),
}))arms.jsPointer geometry, dials, keyboard controls, snapping, and question-arm state.Code excerpt
Dragging starts as a pointer coordinate, becomes an angle around the face, then snaps to the nearest symbol.
function degFromPointer(clientX, clientY) {
const { cx, cy } = getFaceCenter()
const dx = clientX - cx
const dy = clientY - cy
const rad = Math.atan2(dy, dx)
return normalizeDeg(rad * (180 / Math.PI) + 90)
}
function snapToSymbol(d) {
return Math.round(d / SYMBOL_STEP_DEG) * SYMBOL_STEP_DEG
}answer-arm.jsIdle motion, directed rotations, reading animation, cancellation, and reset behaviour.Code excerpt
If someone changes their question midway through a reading, the old asynchronous animation is invalidated instead of continuing with stale state.
function cancelReadingIfQuestionChanged(
getQuestionDegs,
{ pauseMs },
) {
if (!idle.isReading || !activeReading.questionSymbolDegs) {
return false
}
const currentDegs = currentQuestionSymbolDegs(getQuestionDegs)
const changed = currentDegs.some((deg, i) => {
return deg !== activeReading.questionSymbolDegs[i]
})
if (!changed) return false
return cancelActiveReading({ pauseMs })
}reading-engine.jsWeighted sequence generation, timing, repeated turns, and interpretation depth.Code excerpt
The number of full rotations around a symbol becomes part of the reading itself.
function generateTurnCount() {
const weights = [
{ turns: 3, weight: 15 },
{ turns: 2, weight: 25 },
{ turns: 1, weight: 25 },
{ turns: 0, weight: 35 },
]
const totalWeight = weights.reduce(
(sum, item) => sum + item.weight,
0,
)
let roll = Math.random() * totalWeight
for (const item of weights) {
roll -= item.weight
if (roll <= 0) return item.turns
}
return 0
}
export function formatInterpretationDepth(level) {
if (level <= 1) return "primary"
if (level === 2) return "secondary"
return "deep"
}panel-ui.jsTabs, dictionary content, reading cards, spotlight rendering, and automatic symbol previews.Code excerpt
The previews inspect the actual visible pixels in each PNG so differently shaped drawings can be automatically scaled and nudged toward their visual centre.
const { data } = ctx.getImageData(0, 0, cw, ch)
let minX = cw
let minY = ch
let maxX = -1
let maxY = -1
for (let y = 0; y < ch; y++) {
for (let x = 0; x < cw; x++) {
const a = data[(y * cw + x) * 4 + 3]
if (a > alphaThreshold) {
if (x < minX) minX = x
if (y < minY) minY = y
if (x > maxX) maxX = x
if (y > maxY) maxY = y
}
}
}
const bboxW = maxX - minX + 1
const bboxH = maxY - minY + 1
const bboxMax = Math.max(bboxW, bboxH)
const autoScale = (imgMax / bboxMax) * targetFilltutorial.jsFirst-run onboarding and the responsive tutorial experience.Code excerpt
The tutorial tests several possible positions for its card and chooses the one that covers the least amount of whatever it is currently explaining.
const scoredCandidates = candidates.map((candidate) => {
const candidateRect = {
left: candidate.left,
top: candidate.top,
right: candidate.left + cardWidth,
bottom: candidate.top + cardHeight,
}
const overlap = targetRects.reduce(
(total, targetRect) =>
total + overlapArea(candidateRect, targetRect),
0,
)
return { ...candidate, overlap }
})
scoredCandidates.sort((a, b) => a.overlap - b.overlap)
const best = scoredCandidates[0]main.jsApplication initialization and the wiring that lets the modules communicate.Code excerpt
The main file acts mostly as the coordinator, passing shared state and small callbacks between otherwise separate controllers.
const panelUI = createPanelUI({
state,
elements,
SYMBOL_RING,
symbolForDeg,
onConcentrate: () => runConcentrate(),
onClearReading: () => clearReadingAction(),
})
const answerArm = createAnswerArmController({
state,
applyArm: armsController.applyArm,
})
const tutorial = createTutorial({
rootEl: document.getElementById("tutorialOverlay"),
state,
elements,
SYMBOL_RING,
panelUI,
armsController,
answerArm,
})What I learned
Playful ideas still deserve serious engineering
A single interactive object ended up touching state management, geometry, animation timing, procedural generation, responsive design, accessibility, and visual design.
It's exactly the kind of project I enjoy: start with something slightly ridiculous, take the implementation seriously, and see how polished the result can become.