People hear "text game" and think it's simple. Type some words. Show them on screen. Maybe add a blinking cursor. Done.
I've spent more engineering hours on how text appears in Room 337 than on any other system in the game. Gone back to it over a dozen times. And most of it is invisible. The player will never think about it. They'll never notice it. But they'd feel it if it was gone. That's the job. Build things nobody sees so they never get pulled out of the moment.
Room 337 is a psychological narrative game. You play as a father exploring dreamlike liminal spaces, looking for his daughters. It has voice acting, an original score, screen effects, mini games. But at its core, it's a text game. Words are the medium. Words are the graphics. And if the words don't feel right, nothing else matters.
Every line of text types out character by character. You've seen this in games before. Oldest trick in the book. But the default implementation is terrible.
Most typewriter effects just set a timer and advance one character at a time. Same speed. Same rhythm. It reads like a robot. There's no feeling behind it. So I gave the typewriter moods.
When a character is in a hospital scene, the text types faster. Sterile. Clinical. When they're desperate, it drags. When the scene is dreamlike, it floats. Punctuation gets extra weight too. Periods pause. Commas breathe. Ellipses linger. The words have rhythm because the characters have feelings.
// How fast text types based on the emotional context of the scene
export const EMOTION_SPEED_MULTIPLIERS = {
neutral: 1.0, // Normal reading pace
clinical: 0.8, // Fast, sterile. Hospital reality
tense: 1.1, // Slight hold, building
desperate: 1.35, // Weight without crawling
dreamlike: 1.25, // Floaty, detached
weighted: 1.5, // Gut punch moments
};And then there's hesitation. Certain words make the typewriter pause after it types them. Not every emotional word. I tried that. It was too much. Pauses everywhere lose their impact. So I trimmed the list down to six words. Just the ones that really matter to the story. Words that should sit in the air for a second. Words that mean something to my daughters' characters, to the father looking for them.
export const HESITATION_KEYWORDS = [
'forgotten', 'choose', // ... and four others
];Six words. 300 milliseconds of silence after each one. That's it. When the typewriter types one of those words and then just... stops for a beat, you feel it. You don't know why. You just do.
There's a second typewriter system that I'm honestly kind of proud of.
The main character is a father in a dreamlike space. He's confused. His thoughts shift. He starts thinking one thing, then his brain catches up and corrects itself. So I built that into the text.
The typewriter types a thought. Pauses. Backspaces. Types the corrected thought. All in real time, character by character.
Here's what it looks like in the game's content:
[I should be watching.|Why aren't I watching?]The player sees "I should be watching." type out. A pause. The text erases itself letter by letter. Then "Why aren't I watching?" types in its place.
That's not a typo being fixed. That's a father's guilt rewriting his thoughts in real time. He starts with observation and ends with self blame. The mechanic only works in a text game. You couldn't do this in a cutscene. You couldn't do it with voice acting. The medium is the message.
Other examples from the game:
- •
[He knows her.|How does he know her?]Observation becomes suspicion - •
[The oldest.|Someone had to.]Recognition becomes grief - •
[I recognize this melody.|I almost recognize this melody.]Certainty slipping
Each one is a tiny story. A thought catching itself. And because it happens through the typewriter, through the same system the player has been reading the whole game, it hits different than a scripted animation would.
Here's where it gets weird.
Text types out character by character. The container needs to hold it. But the container doesn't know how tall the finished text will be until it's done typing. So as each character appears, the container grows. Every new line shifts everything below it down by one line height. You see a tiny jump. A subtle shift. Barely perceptible. But in a game where you're staring at nothing but text for hours, it's like a scratch on a record.
So I built a two layer rendering system.
Layer 1 is invisible. It contains the complete text of the line, fully rendered, but with visibility: hidden. You can't see it. But it takes up space. It tells the browser exactly how tall the container needs to be before a single visible character appears.
Layer 2 is the actual text the player sees. It's absolutely positioned on top of Layer 1, typing out character by character. The container never shifts because Layer 1 already claimed the space.
{/* Layer 1: Invisible full text. Reserves the height */}
<span aria-hidden="true" style={{ visibility: 'hidden' }}>
{fullTextContent}
</span>
{/* Layer 2: Visible partial text, typing out on top */}
<span style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
{textContent}
{isTyping && <Cursor />}
</span>Two spans. One invisible. One visible. Nobody will ever know.
The two layer system solved the height problem. Then it created a new one.
When text reaches the end of a line, the word wraps to the next line. Normal browser behavior. Except with character by character typing, you see it happen. The word starts appearing at the end of line one. Character by character. Then the moment it gets too long, the whole word snaps down to line two. A flash. One frame. Maybe two. But enough to catch your eye.
On a website, you'd never notice. In a game where you're watching every letter appear, it drove me nuts.
The font is monospace. Every character is the same width. Simple math, right? Measure the container, divide by character width, break before any word that would overflow. I went through canvas measurement, DOM measurement, hidden probe spans — the numbers got close enough. But it still flashed.
// Hidden probe: 50 M-characters in the real container
const span = document.createElement('span');
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'pre';
span.textContent = 'M'.repeat(50);
container.appendChild(span);
// Measure what the browser actually renders
const charWidth = span.getBoundingClientRect().width / 50;
const charsPerLine = Math.floor(availableWidth / charWidth);The problem was more fundamental than measurement.
The text types out character by character. The word "something" starts as "s", then "so", then "som", then "some"... My soft wrap algorithm checked: does the partial word fit on this line? Yes. Yes. Yes. Yes. Does "something" fit? No. Move it down.
But the player just watched "someth" appear on line one. And then "something" jumped to line two. Same flash. My own wrapping code was causing it instead of the browser's.
The fix was obvious once I saw it. Don't wrap the partial text. Wrap the full text. Then apply those break positions to the partial text as it types.
// Step 1: Wrap the FULL text to determine where every word lands
const fullWrapped = softWrapLines(fullText.split('\n'));
// Step 2: Apply those exact breaks to the partial (typing) text
const displayWrapped = applyFullBreaks(fullWrapped, displayText);Now the word "something" is always on line two. From the very first "s". Because the algorithm already knows where the complete word ends up. The player sees the word start typing on line two immediately. No flash. No snap. Just smooth text appearing where it belongs.
A ResizeObserver watches the container. When the window changes size, the characters per line recalculate, the soft wrap recalculates, and both layers rerender. Desktop, mobile, orientation changes. All of it.
The wrap system has to play nice with everything else. Speaker names are colored. The main character's internal thoughts are italic. Highlighted words glow in the location's accent color. The correction typewriter has strikethrough text that animates letter by letter.
All of these are visual ranges. Spans of text with specific styles. When a line gets soft wrapped into two display lines, those ranges need to be remapped. A highlight that starts at character 15 and ends at character 25 might now span two display lines. So there's offset math that adjusts every range to account for where the soft wrap split the line:
// Map authored positions to the local display sub-line
const start = Math.max(0, range.start - charOffset);
const end = Math.min(text.length, range.end - charOffset);All of this runs on every character typed. It has to be fast. The actual wrapping is just string splitting. Cheap. The measurement is one DOM read on resize, not per character. The visual range remapping is array math. None of it is individually expensive. But it all has to work together, every frame, without the player ever seeing a seam.
One more small thing. The cursor only blinks when the typewriter is paused. When text is actively typing, the cursor just sits there at the end. Solid. When there's a hesitation pause, a dramatic beat, it starts blinking. A tiny detail that makes the pauses feel intentional instead of broken.
There's more I haven't covered. Static effects that scramble text during tense moments. A screen shake system with configurable intensity. Five audio layers mixed in real time — ambient, music, sound effects, voice, and a heartbeat that changes speed with the tension. Each of those could be its own post. Maybe they will be.
In a game with graphics, you have a hundred ways to create immersion. Lighting. Animation. Camera angles. Particle effects. In a text game, you have words. And how those words appear is everything.
Every flash, every jump, every weird pause pulls the player out. And the only way to keep them inside the story is to make the technology disappear.
It's obsessive. I know.
But this game is about my family. My real family. My daughters voice their own characters. The story is about things that actually happened. If I'm going to ask someone to sit with that, the least I can do is make sure a word doesn't flash on the wrong line for a frame.