This is a submission for the Hacktoberfest Week 4 Challenge: Ghost in the Machine.
What I Built
Haunted Terminal is a short, spooky, choose-your-path story that happens inside an old green-screen terminal.
You switch on a computer you found. The cursor starts moving on its own. It types: WHO IS THERE?
That's Marlowe. He was a writer who typed his stories on this machine until one winter night, when the power cut out mid-sentence and left him stuck on the wrong side of the glass. He wants someone to help him finish his last story, and that someone is you.
Each turn, Marlowe's story continues in a short passage (50 to 80 words), and you pick one of three choices or type your own action. After five choices you reach an ending that follows from what you did. Every passage can be read aloud by a narrator, so it feels like someone is telling you the story in the dark.
I took "Ghost in the Machine" literally. The ghost is the machine: an open-weight language model wearing a dead writer's voice, typing in capitals on a screen it can't leave.
As a kid, I loved Choose Your Own Adventure books. Haunted Terminal is my grown-up version: the same "turn to page 42 if you open the door" thrill, except the book writes itself while you read. And I liked the idea of a literal ghost in the machine.
Demo
Play it live: haunted-terminal.vercel.app
Watch the demo: youtu.be/K-CVyUl0Pvk
A playthrough looks like this:
- Marlowe types his first words. A "passage 1 of 6" counter appears in the title bar.
- You press 1, 2 or 3 (or click, or type your own action).
- The next passage starts appearing within a few seconds, word by word, while "The ghost is typing..." shows in the status line.
- Press Listen (or turn on Auto-narrate) and the passage is read aloud.
- After the ending, Copy story copies the whole tale so you can share it, and Restart begins a new one.
No API keys? The app still runs in demo mode with a canned story, and it tells you which key is missing.
Code
Haunted Terminal
Hacktoberfest Week 4, theme "Ghost in the Machine" (Oct 26-31, 2026) A ghost named Marlowe lives in a terminal and tells a branching spooky story. The reader picks choices (or types an action), Gemma writes the next passage, and ElevenLabs narrates it.
Stack
- Next.js (App Router), plain JavaScript, no database. Story state lives in the page; nothing is stored server side.
- Story: Gemma 4 26B (open weights). Fastest path is
GEMMA_MODE=routerwhich streams it from OpenRouter (a few seconds to the first word); Google AI Studio (GEMMA_MODE=cloud, thinking off viathinkingLevel: "minimal") works but took about 40 seconds to start in testing.GEMMA_MODE=localstreams from Ollama. The Gemma and ElevenLabs calls mirror Talk to the Book's proven routes. Story turns stream: passages appear as Gemma writes them instead of after one long wait. Passages are 80 to 130 words to keep turns snappy. - Narration…
How I Built It
The stack
- Next.js (App Router), plain JavaScript, no database. The whole story lives in the page. Nothing is stored on a server.
-
Story: Gemma 4 26B (
gemma-4-26b-a4b-it), served through OpenRouter. This is the core of the project. Without the model there is no Marlowe and no story. - Narration: ElevenLabs text-to-speech, one passage at a time.
Making Gemma into Marlowe
All of Marlowe lives in one system prompt. A few rules did most of the work:
- A fixed point of view. You are always "you," in second person, present tense. Marlowe never narrates. He only speaks by typing on the screen, and his words always appear in CAPITALS. Early drafts mixed "I" and "you" and the ghost kept wandering into the narration. Pinning this down made the story feel much more like a haunting and much less like a chatbot.
- A shape for the whole story. About six passages: the mystery, the truth about Marlowe, a climax, then an ending. The route tracks how many choices you've made and tells Gemma when it's writing the climax (three choices that lead to different endings) and when it's writing the final passage (no choices at all). Earlier versions never ended. Five choices gave the story a real arc.
- Spooky, never gory. PG and playful, no real-world tragedy, and Marlowe never breaks character or claims anything is real.
-
A machine-readable tail. Every passage ends with a
===CHOICES===line and exactly three choices. The server checks that shape before it sends the choices to the page. If the model forgets the marker, the reader gets fallback choices and an honest notice instead of a broken screen.
Making it fast enough to feel haunted
A ghost that takes 40 seconds to answer isn't scary. It's just slow. This was the hardest part of the build.
- Streaming end to end. The story route reads Gemma's server-sent events and passes each chunk to the page as it arrives, so the text appears while it's being written, like Marlowe typing in real time.
-
The CRLF bug. My first streaming attempt felt no faster than waiting for the whole reply. The cause: Google AI Studio separates its stream events with
\r\n\r\n, and my parser only looked for\n\n. It never found a boundary, so the whole reply showed up at once at the end. Normalizing line endings fixed it. -
Turning thinking off. Gemma 4 ignores
thinkingBudget: 0(the Gemini 2.5 setting) and only acceptsthinkingLevel, where"minimal"means off. With the wrong setting it thought silently until the request timed out. -
Router mode. Even with both fixes, Gemma 4 26B on AI Studio took about 40 seconds to show its first word in my testing. So I added
GEMMA_MODE=router, which streams the same open-weight model from any OpenAI-compatible host. On OpenRouter the first words show up in a few seconds. The app also supportsGEMMA_MODE=cloud(AI Studio) andGEMMA_MODE=local(Ollama on your own machine), and the route logs how long the first text took so slow turns are easy to spot. -
Streaming narration. The narration route calls ElevenLabs' streaming text-to-speech endpoint and pipes the audio straight through to an
<audio>element, so playback starts after the first chunk instead of after the whole passage is ready. Gemma writes the words and ElevenLabs speaks them, so the ghost both types and talks. A speed control lets you slow the voice down or speed it up, and playback is always user-started unless you turn on Auto-narrate. The narrator is Callum, one of ElevenLabs' premade voices. His husky, intense delivery sounds like someone telling you a story in a dark room: eerie, but never gory.
Making it playable for everyone
Before submitting, I ran an accessibility audit against WCAG 2.2 AA using code review, contrast math, an axe-core scan, and a keyboard-only playthrough. A terminal UI where text streams in is a tricky case, and the audit found real problems:
- Focus fell off the page. Picking a choice removes the button you just pressed, so keyboard focus jumped back to the top. Now focus moves to the newest passage after each turn (and after Restart), and Tab goes straight to Listen and the next choices.
- Streaming text and screen readers don't mix. Making the streaming text a live region would read out dozens of fragments. Instead, the half-written text is hidden from assistive tech, a status line says "The ghost is typing...", and the finished passage is read once when it gets focus.
-
Classic terminal green failed contrast. The dim green (
#1f8a3a, 4.38:1) and dark borders (1.55:1) were too faint. They're now#2e9e4a(5.62:1) and#3a7a45(3.73:1), and every button and input has a visible green focus ring. -
Smaller fixes: a label on the custom action input,
aria-pressedon the narration speed buttons, notices in arole="status"region,>prompt glyphs hidden from screen readers (so they read "You chose: ..." instead of "greater than"), a visually hidden page heading, and the blinking cursor and smooth scrolling both stop underprefers-reduced-motion.
After the fixes, axe reports zero violations and you can play from start to finish with only a keyboard. I also ran a minimal test with VoiceOver on a Mac.
Small things that make it nicer to play
- Press 1, 2 or 3 to choose.
- A passage N of 6 counter so you know how close the ending is.
- Try again if a turn fails, without losing your story.
- Auto-narrate, remembered per browser, reads each new passage after you choose.
- Copy story copies the full transcript when you finish.
- Restart works at any point, with a confirm.
Why Does Open Innovation Matter?
Using an open-weight model changed how I built this in two ways.
I could move the model when the host was too slow. When Gemma on AI Studio took 40 seconds to start talking, I wasn't stuck. The same weights are served by other providers, so I added one config switch and moved the story to OpenRouter without changing a single word of the prompt. With a closed API, a slow host is the only host.
It can run on your own machine. GEMMA_MODE=local runs the exact same story against Gemma in Ollama. No key, no account, no data leaving your computer. For a project about a ghost trapped inside a computer, it felt right that the ghost could actually live on yours.
The Spirit of It
I relate to Marlowe more than I expected to. He's a writer who ran out of time mid-sentence and needs someone to help him finish his last story. I'm also working on finishing a book. My last challenge submission was a tool to help me do that.
So Haunted Terminal is a bit of a self-portrait. Every unfinished manuscript has a ghost in it, waiting at a blinking cursor for someone to come back and type the next line. Marlowe just gets to ask out loud.
Prize Categories
-
Best Use of Gemma: the whole story is written live by Gemma 4 26B (
gemma-4-26b-a4b-it), an open-weight model, served through OpenRouter, with AI Studio and local Ollama as alternate paths. - Best Use of ElevenLabs: ElevenLabs gives the open-model story its voice. Every passage Gemma writes can be read aloud with ElevenLabs text-to-speech, streamed so playback starts on the first chunk, with a narration speed control and an Auto-narrate mode that reads each new passage as it lands.
Credit: the Gemma and ElevenLabs route code is adapted from my earlier project, Talk to the Book. Haunted Terminal is a new project built during the Week 4 window. Thanks for reading.
Top comments (0)