Case study

All at the Clocktower

A companion app that lets blind and visually impaired players play Blood on the Clocktower with more independence, without taking the people out of the game.

Role
Solo design engineer: research, interaction design, visual design, frontend, backend, testing
Timeline
June 2025 to Present
Built with
Next.js, React, TypeScript, Tailwind, Convex
Tested with
Blind players, VoiceOver on iOS, TalkBack on Android
Storyteller
The Storyteller's Grimoire on a phone: eight tokens in a circle showing each player's character, such as Slayer, Imp and Scarlet Woman. Dana's token carries a death shroud and seat 6 is an empty chair. Below are seat controls, alive and vote counts, and the Setup, Bag, Grim and Night Order tabs.
Player
A player's phone showing a night prompt titled Fortune Teller, with players listed from the viewer clockwise, two of them selected, and a Submit (2 of 2) button.
The Storyteller sees every character; each player sees only what's theirs. Every screenshot on this page was captured from the running app during a test game.

At a glance

The problem
Blood on the Clocktower is played by looking: at your token, at the table, at the Storyteller's hand.
My role
Everything, alone: research, interaction and visual design, the app and its real-time backend, and testing with blind players.
What I built
A companion for the whole table, with a version of the board you can listen to and private night prompts on each player's phone.
What happened
Two blind players played a full game with it after usability sessions with blind players. Both said the work was meaningful to them.

A game you play by looking

Blood on the Clocktower is a social deduction game for up to twenty people sitting in a circle. A Storyteller runs it from the Grimoire, a board of tokens laid out in the same circle. Players learn their secret character from a token, act at night by pointing and nodding, and track who is alive by watching the table.

The game publishes accessibility guidelines for blind and visually impaired players. Most of the accommodations route information through the Storyteller, who is already the busiest person at the table. Reading those guidelines is where this project started.

How might blind and visually impaired players get more of the game directly, without changing the social experience that makes it special?

The principle I held the whole way through: increase player autonomy without replacing human interaction. The app should hand players what their eyes would have given them, and leave the talking, lying and accusing to the people.

Blind players were the reason for the project from the start. I wanted to build something the whole table would enjoy using, while giving blind players more independent access to the game. So I built a strong shared experience first, with accessibility in it from the very first page, then went deep on the screen-reader interactions.

A circle you can listen to

In short: players hear the table as a list that starts at their own seat and goes clockwise, so the person next to you is your next swipe.

The Grimoire is spatial. Who sits next to whom matters, and a screen reader flattens a circle of buttons into an arbitrary list. Before, a screen reader met the circle's own seat buttons in page order, starting at seat 1, with nothing to tell you which one was you. Making each seat accessible was not enough. A blind player needed the table itself.

Eight seats in a circle. The spoken order starts at your seat, Ilya in seat 3, and goes clockwise: Ilya, Marcus, Dana, empty seat 6, Priya, Tom, Sara, Eli. 7 Sara 8 Eli 1 You 2 Marcus 3 Dana 4 Seat 6 5 Priya 6 Tom

Before: the circle's own buttons

  1. “Seat 1 - Sara”
  2. “Seat 2 - Eli”
  3. “Seat 3 - Ilya”
  4. “Seat 4 - Marcus”
  5. and so on, from seat 1, with no “you”

After: a table that starts with you

  1. “Ilya, seat 3, you, Imp”
  2. “Marcus, seat 4”
  3. “Dana, seat 5, dead”
  4. “Empty seat 6”
  5. and on around the circle, clockwise
The numbers show the spoken order. Swipe right from your own seat and you hear your neighbours in the order they sit. The “before” rows are the circle's old seat labels; the “after” rows come from the app's accessibility tree.

So players get a second, invisible version of the board: a heading that orients them, then one button per seat. The visual circle is hidden from assistive tech, so nothing is read twice. It still works by touch for low-vision players.

Player
A player's phone showing the Grimoire: eight seats in a circle with player names. Ilya's own token shows the Imp, Dana has a death shroud, and seat 6 is an empty chair.

The same screen, as a screen reader reads it, one swipe per line

  1. Heading: “Game table, 8 players. Starting from you, going around the circle clockwise.”
  2. “Ilya, seat 3, you, Imp”
  3. “Marcus, seat 4”
  4. “Dana, seat 5, dead”
  5. “Empty seat 6”
  6. “Priya, seat 7”
  7. “Tom, seat 8”
  8. “Sara, seat 1”
  9. “Eli, seat 2”
Left: what a sighted player sees. Right: the accessibility tree exported from that same screen.

Decisions that came from listening

  • Seat numbers, after all. I started with names only, the way players talk about each other. Listening changed my mind: numbers give a listener something to anchor on, and several rows of “Empty seat” were impossible to tell apart.
  • Not a list. TalkBack announced “out of list, Game table, 8 players” every time a prompt took focus away from a seat. A heading and plain buttons read cleanly. I gave up “item 3 of 8” to get that.
  • Only the exception is spoken. “Dead” is said; “alive” is not. Rows stay short at a twenty-seat table.

One seat label, then and now

  1. 2025“Seat 0 - Sara”
  2. June 2026“Sara, Empath, alive”
  3. Now“Sara, seat 1, Empath”
Deep dive: all six versions of the seat label
  1. Jun 2025 “Seat 0 - Sara” My first label. Zero-based, and an empty seat stopped at “Seat 0 -”.
  2. May 2026 “Seat 1 - Sara” · “Seat 1 - empty” Counting from one, and empty seats say so.
  3. Jun 2026 “Sara, Empath, alive” · “Empty seat” The new table. Names only, the way players refer to each other.
  4. Same evening “Empty seat 1” Five identical “Empty seat” rows were impossible to tell apart.
  5. Jul 2026 “Sara, seat 1, Empath” Numbers on every row give a listener an anchor. “Alive” is the default, so it went silent.
  6. Sep 2026 “Kate, seat 3, empty” The Storyteller can name an empty seat for a player who has no phone.

Testing at the table

I tested on real phones throughout with VoiceOver and TalkBack. Most of the decisions on this page came out of those passes, where the two screen readers often wanted opposite things.

Then I ran focused usability sessions with blind players, and finally two blind players played a complete game with the app. The full game mattered most, because it showed problems no isolated screen ever would.

Observed
Blind players ran into problems I hadn't caught testing on my own. The full game surfaced barriers outside the app, like voting.
Changed
I changed the app based on what players ran into in the sessions. The screen-reader structure covered on this page came out of my VoiceOver and TalkBack testing.
Validated
Two blind players played a complete game with the app. Their feedback was positive throughout, and both said the work was meaningful to them.
Unresolved
Night prompt wording still needs tuning with blind players, and a blind Storyteller isn't supported yet.

The finding that wasn't about software

The clearest one was voting. When a player is nominated, the Storyteller moves a hand around the circle, counting raised hands. Sighted players see who voted. A blind player gets only the total, and in this game who voted is strategic information.

The fix we found was spoken, not built: the Storyteller says each player's name and their vote as the hand passes, then the total. It takes a little longer, and it gives blind players the same picture of who voted that sighted players get by watching. The official guidelines recommend the same thing; playing a real game showed why it matters.

Not every accessibility problem needs software. Sometimes the right design is a sentence the Storyteller says out loud.

Night prompts: a teleprompter, not an oracle

In short: the Storyteller sends one player a private prompt; the player answers on their phone with a screen reader; the app never decides the answer.

At night, players wake one at a time and act by pointing and nodding with the Storyteller. A blind player can't see where the Storyteller is pointing, or point back without help.

Night prompts give the Storyteller a private channel to one player's phone. The phone takes focus, reads the instruction, and offers simple controls: pick a player, pick several, pick a character, answer yes or no. Nothing is sent until the player confirms, and when the Storyteller closes the prompt its content is destroyed on the server.

The key decision was what the app should not do. It never works out the answer. In this game the Storyteller is allowed to lie to a drunk or poisoned player, so the truth lives with them. The app pre-fills a sensible prompt from the character, and the Storyteller edits it before sending.

  1. Storyteller
    The Storyteller's 'Send a night prompt' form, addressed to Ilya: prompt type 'Choose player(s)', title 'Fortune Teller', a short body, how many to choose set to 2, and a 'Send to Ilya' button.
    Picks a seat, edits a pre-filled prompt, sends it.
  2. Player
    Ilya's phone: 'Fortune Teller', the instruction, a filter field, the players listed from Ilya clockwise with Marcus and Priya selected, and 'Submit (2 of 2)'.
    Focus lands on “Fortune Teller”. Players are listed from them, clockwise.
  3. Player
    Confirmation on the phone: 'You chose Marcus and Priya. Confirm, or go back.' with Confirm and Go back buttons.
    Hears “You chose Marcus and Priya. Confirm, or go back.”
  4. Storyteller
    The Storyteller's view of the answered prompt: 'Ilya responded', the players chosen, and a 'Close prompt' button with the note 'Closing destroys the prompt and frees you to send the next one.'
    Sees the answer, responds truthfully or not, and closes.

Status: working end to end, with backend tests that check who can read which prompt. The wording still needs tuning with blind players.

Deep dive: building the controls
<button
  type="button"
  aria-pressed={isSelected}
  onClick={() => onToggleCandidate(c.name, count)}
>
  {c.name}
  {isSelected ? ", selected" : ""}
</button>
Before: toggle buttons with the state bolted onto the label.
<div role="radiogroup" aria-label={label}>
  <button
    type="button"
    role="radio"
    aria-checked={checked}
    onClick={() => onChange(opt.value)}
    {...radioProps(opt.value)} // one Tab stop, arrow keys
  >
    {opt.label}
  </button>
After: radio-group semantics on custom buttons, so the screen reader announces the role and the selection itself. The keyboard follows the WAI-ARIA radio pattern: the group is one Tab stop and the arrow keys move and select. Swipe-based screen readers still reach every option.
  • A filter, not a combobox. Picking a character from a whole script uses a text filter above grouped radio buttons. A combobox popup is one of the most fragile patterns across screen readers, and this reads the same while breaking less.
  • Prompts start from a seat, not a role. I first let the Storyteller launch prompts from the night order, then removed it. A night-order row is a character, and two players can hold the same one.
  • My tests passed on fake data. The Spy sees the whole Grimoire, read row by row. It was reading reminder tokens as raw IDs like “imp-Dead”, and my unit tests missed it because I had fed them clean labels that never occur in real data. Now a row reads “Seat 3, Eli is Washerwoman, has reminder: Drunk”, and the tests use the real shape.

Designing for silence

In short: the app stopped narrating the game. It speaks once, when you act, and the Storyteller narrates events.

My first builds announced everything: deaths, people joining, the vote count, every tab change. On real phones that turned into noise.

The best thing a screen reader can say is often nothing.

Before: closing a dialog

  • The alive and votes summary, about three times over
  • A stale “Bob has died.”, again
  • Whatever control was nearby, like “Hide Tokens”

After

  • “Grimoire”
What testing on VoiceOver and TalkBack turned up, paraphrased from my notes. Now the night prompt and character reveal land on the Grimoire when they close; other dialogs return focus to the button that opened them.

I wrote a rule for the whole app: it announces nothing on its own. Game state is there when you look for it, and the Storyteller narrates events, as they already do. The few things that must speak now speak once, from a heading that takes focus.

Player
Reveal screen: the heading 'Your character awaits', a face-down token, and a Reveal button.

Focus lands on the heading

“Your character awaits.”

Swipe once

“Reveal, button”

Player
After revealing: 'Good luck!', the Imp token, its ability text, and a Continue button.

Focus lands on the heading

“Good luck. Your character is Imp. You are a Demon. Each night*, choose a player: they die. If you kill yourself this way, a Minion becomes the Imp.”

Swipe once

“Continue, button”

The character reveal, with the headings taken from the app's accessibility tree. The coin-flip animation is hidden from assistive tech, so the character is never spoken before the player chooses to reveal it. “You are a Demon.” is there because team is otherwise shown only by the token's colour.

Two screen readers, opposite needs

Underneath every overlay is one shared dialog hook. Getting it right meant learning that VoiceOver and TalkBack needed opposite things: one afternoon I removed a fix, then put it back 29 minutes later.

What each screen reader needed from a dialog
Behaviour VoiceOver (iOS) TalkBack (Android)
Page behind the dialog Re-read it when aria-hidden was removed on close Swiped out into it unless aria-hidden was set
Other trouble Read a live-region announcement twice Said “out of list” whenever a prompt pulled focus from the seat list
What worked for both Hide the background with inert and aria-hidden, keep it free of live regions, and place focus on purpose when the dialog closes.
Deep dive: the afternoon, step by step

An automated design review I ran flagged the base modal as the least accessible component in an app built for blind players. That started it.

  1. Jun 17 One shared dialog hook replaces four drifting overlays: focus in, focus back out, Escape, Tab trap, scroll lock.
  2. Jun 26, 13:39 The page behind a dialog becomes inert and aria-hidden, because a Tab trap does nothing for swipe navigation.
  3. 13:59 Hide the background before moving focus, or the screen reader narrates focus leaving the page.
  4. 16:27 Drop aria-hidden. Removing it on close made iOS VoiceOver re-read the page behind.
  5. 16:56 Put it back. Without it, TalkBack's cursor swiped straight out of the dialog. Fix the VoiceOver re-read somewhere else instead.
  6. Jun 27 Every overlay hides the background first, then takes focus, then returns focus to a chosen landmark on close.
 * (5) is the part a plain Tab-trap can't do: VoiceOver/TalkBack browse-mode
 * navigation ignores keyboard focus traps, and `aria-modal="true"` is honored
 * inconsistently (notably iOS VoiceOver). `inert` blocks focus/pointer and
 * removes the background from the a11y tree — but **TalkBack's virtual cursor
 * does not reliably respect `inert` alone**, so without `aria-hidden` the
 * Android cursor can still swipe out of the dialog into the page behind it. We
 * therefore set BOTH.
From the hook's documentation. I wrote down each platform tradeoff in the code, so the next change doesn't undo it.
Deep dive: small details that changed how it sounded
  • Headings, not live regions. Live regions read twice on iOS, and descriptions re-read on every press. Moving focus to a heading reads it once, in order.
  • One string, one breath. VoiceOver reads text split across several nodes as stilted fragments. The strings written for screen readers became single template strings.
  • Labels only where they work. An aria-label on a plain div was silently dropped, so summaries became real visually hidden text.
  • Land somewhere on purpose. Closing an overlay that appeared on its own moves focus to the Grimoire, instead of reading out whatever button happened to be nearby.

A look the whole table wants

Accessible doesn't have to mean plain. I wanted an app everyone at the table enjoys opening, so I gave it a visual language that feels like the game itself: dark and ceremonial, with the information you need lit in gold.

  • Two voices. Gold carries information and the player's actions. Violet is reserved for the Storyteller's authority. No other colour carries meaning.
  • Ceremony in the headings, clarity in the text. A blackletter face for headings, a plain humanist sans for everything you actually read.
  • A legibility floor. Body text never below 16px, touch targets at least 44px, motion reduced when the system asks, and colours defined in OKLCH with fallbacks for older browsers and smart TVs.
  • Restraint. No parchment textures, scrollwork, neon or particles. The atmosphere comes from type, spacing and darkness.

I wrote the system down as a design doc with named rules, then built it as Tailwind theme tokens and a small set of shared components, so every new screen follows it without a debate.

Storyteller
The Create a Room screen: a blackletter 'Create a Room' heading in gold, fields for name, optional pronouns with quick-pick chips, room name, game script and a seat-count stepper set to 8.
Storyteller
The Storyteller's bag on a phone: team counts at the top, toggles, Fabled, Loric, Randomize and Clear Bag buttons, a character search, and character cards such as Washerwoman and Librarian with their abilities, above a violet 'Send Bag to Players' button.

Colour tokens

  • Ink --color-bg Page background
  • Gold --color-foreground Information, primary action
  • Soft gold --color-foreground-soft Body text
  • Vivid gold --color-foreground-vivid Primary hover
  • Violet --color-accent Storyteller actions
  • Light violet --color-accent-light Accent focus ring

Type

Create a Room

Grenze Gotisch 600, headings only

Select other seats to assign characters or take notes. Your grimoire is private.

Open Sans 400, body never below 16px

Buttons and focus

Primary (gold), accent (violet, Storyteller actions), ghost, and a keyboard focus ring: 2px, offset 2px, in a colour drawn from the button's own voice.

Selection

Radio and checkbox semantics on large cards. The checked state changes the border, fill and text, not just the colour.

A specimen rebuilt from the app's production tokens (globals.css) and component styles (Button, RadioCardGroup). The samples are pictures of controls, not working controls.

Real time, with secrets

In short: every phone sees the same game the instant it changes, and other players' secrets stay off it.

Players sometimes never received their character, because the message arrived before their phone was listening. I moved the real-time layer from a self-hosted Colyseus server to Convex, incrementally, and deleted 1,371 lines in a day. With subscriptions, a phone that connects late still gets its role.

One leaked character ruins a game. Roles live in their own table, and a phone looks up its role with its own device ID. Public queries strip those IDs out, so one player has nothing to look another up with. The Storyteller's draft assignments stay on their device until they press Send.

Reviewing the architecture, I found that the room query was sending the Storyteller's secret token to every client. Anyone with developer tools could have read every role. I fixed it by returning a public projection of the room, and added a test that fails if either secret ever comes back. Later I found the same kind of leak in night prompts, where the player list carried other players' IDs, and closed it the same way.

There is no login, on purpose. A sign-up screen is a barrier for someone who just wants to join the game at the table, and more so with a screen reader. Instead, each phone gets a random ID, the Storyteller's phone also gets a host token, and every Storyteller action checks that token. That limits what reaches each phone. It doesn't prove who is holding it: anyone who got hold of another phone's ID could read that player's role. That's why keeping those IDs out of every query matters so much.

Deep dive: the fix
// Public projection: hostToken gates every host query/mutation, and
// hostUserId would let anyone call getMyRole as the host. Neither may
// ever reach a client. Hosts prove identity via verifyHostToken below.
const { hostToken: _hostToken, hostUserId: _hostUserId, ...publicRoom } =
  room;
return publicRoom;

Smaller decisions

Tap to move, not just drag
Rearranging seats needed a long press and a drag. I added a move mode with real buttons for each destination, and Escape to cancel, so it works by keyboard and screen reader.
Problems before the button, not after
Sending characters used to fire error toasts after the press. Now a confirm dialog lists blockers and warnings first, and Send stays disabled until the blockers are fixed. Storyteller 'Send Bag to Players?' dialog on a phone, with a warning: '1 seat has no player. That character won't reach anyone.' followed by advice, then Send to Players and Cancel buttons.

Working with AI

I was the only person on this project, and I worked with AI agents as collaborators throughout. They helped me prototype interaction patterns, compare technical approaches, run design and accessibility reviews, and implement and debug features. Some larger pieces, like the first version of the night prompt engine and its starter copy, were built by agents working from specs I wrote.

The product direction, the design decisions and the validation stayed with me. An AI review can tell you a modal is missing a role. It can't tell you that TalkBack escapes a dialog that VoiceOver contains, or that voting needs a spoken ritual. That came from phones in hand and blind players at the table.

What worked

  • A real-time multiplayer companion that works end to end, live at allattheclocktower.com.
  • Testing that held up at a real table: focused sessions with blind players, then a complete game with two of them.
  • Saying less. Cutting automatic announcements removed the repeated and stale speech that testing turned up.
  • Knowing when not to build. Voting became a spoken practice, not a feature.

What's next

  • A blind Storyteller. The listening version of the Grimoire is for players. The Storyteller still uses the visual circle.
  • More time with blind players. More sessions and more full games, to keep finding what only real play reveals, like the voting ritual.
  • Accounts, only if they earn their place. Some future features may need them. Until then, joining a game takes a room name and your name, nothing more.

Try it yourself at allattheclocktower.com.

Interested in this work? Email me at ilya.m.polyakov@gmail.com or go back to the home page.