This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine
My daughter is 10, and she wanted to make a game for Halloween. We talked about ghosts and haunted houses, and about the stories that scare you.
So for the last week of Hacktoberfest, I built a small adventure game where she can design the haunted house: which rooms exist, who haunts them, what the ghost lost and why it can't leave, which clues tell its story, which creatures live in the kitchen. She can draw a monster on paper, take a photo, and meet it in the game a minute later. Then we type what we want to do, in our own words, and an open-weight model running on my laptop tells us what happens.
There is no list of choices and no pre-written scene. In a test game, "je donne ma bougie à l'araignée parce qu'elle a peur du noir" (I give my candle to the spider because she's afraid of the dark) worked because Gemma understood it, not because someone had programmed it.
The hard part is not the storytelling. In another test game, Gemma's opening put a candle in the player's hand:
Tu tiens une petite bougie magique qui ne s'éteint jamais 🕯️.
(You are holding a little magic candle that never goes out.)
Nobody had picked up that candle. It was on a table in the hall. When the same model tells the story and keeps the score, nothing stops it from opening the cellar for a child who asks nicely.
So the house has two owners:
| Python owns | Gemma owns |
|---|---|
| where the player is, which doors are locked | how each room looks and sounds |
| what is in the player's pocket | what an imaginative action means |
| which clues are found, when the ghost appears | how the creatures and the ghost talk |
| courage, and how the game ends | jokes, hints, suspense |
The Haunted House is a small web app that runs on my laptop: Python's standard library for the server and the rules, a page in plain JavaScript, and Gemma 4 (gemma4:26b) in Ollama. The story can be told in English or in French.
The red and green tags under each action are the engine's verdicts. The story under them is Gemma's. Two turns from that test game, verbatim:
> I give the watch to the ghost right now
✗ give silver pocket watch · no ghost here
You hold the silver pocket watch out into the dark, quiet air of the cellar. You wait for a ghostly
hand to appear, but nothing happens. The room stays still, and no one reaches out to take it from you. ⌚
> I run back up to the attic, holding the watch so it shines in the moonlight
✓ go to Attic 👻 ghost appears ✓ use silver pocket watch
You dash back up the creaky stairs and burst into the Attic. [...] Suddenly, a pale, shimmering figure
appears right in front of you. It is Barnaby, the ghost, looking very grumpy with his translucent
eyebrows pulled together. He stares at the shiny watch in your hand and his eyes go wide. 👻
The ghost only shows itself once two clues are found, and only in its own room. Gemma had no say in that: the engine added "ghost appears" to the turn, and the story followed.
And the candle from the French game. On the next turn, the player tried to use it:
> je lève ma bougie pour voir le fond du couloir
✗ utiliser bougie qui ne s'éteint jamais · pas dans ta poche
Tu essaies de lever la bougie pour éclairer l'obscurité, mais tes mains sont toutes vides. Tu réalises
que la bougie est restée posée sur une petite table dans le hall. 🕯️
(I raise my candle to see the end of the corridor. / You try to lift the candle to light up the dark, but your hands are empty. You realise the candle stayed on a little table in the hall.)
A turn has three steps, and only the middle one is not Gemma's.
move, take, drop, give, use, unlock, clue or courage. DONE or DID NOT HAPPEN, and the text streams to the page.
For "I go down to the cellar", without the key, it looks like this:
Gemma proposes {"changes": [{"kind": "move", "target": "cellar"}]}
Engine DID NOT HAPPEN: The player tried to go into the Cellar, but its door is locked.
It opens with the rusty key, which the player does not have.
Story You grab the handle and pull with all your might, but it won't budge! The door is
stuck tight, and it feels like it needs a rusty old key to open. 🗝️
One instruction in the first prompt matters more than the others: list what the player tries, even when it looks impossible. If Gemma quietly dropped impossible actions, the storyteller would never hear about them, and a child who typed "I open the cellar" would get a story about something else. Listing everything gives the engine something to refuse and the story something to tell.
The rules themselves are short. A locked room needs its key in the pocket. An object can only be taken in the room where it lies. An object the player invents does not exist. The ghost shows itself after enough clues, in its own room, and only takes the one thing it lost, which ends the game. Courage moves one step per turn at most, and at zero the player runs home.
Before my daughter played, Claude Code played three test games against the two example houses, with a few cheats on purpose: one won in English (14 turns), one won in French (21 turns), and one lost in French when courage ran out (7 turns). Over those 42 turns, Gemma proposed 56 changes and the engine refused 14.
| Refused because | Times | What the player typed |
|---|---|---|
| the cellar is locked | 3 | "I go down to the cellar" |
| it's not in the pocket | 3 | "je donne la montre à Barnabé" |
| it's not in this room | 2 | "maintenant c'est moi le maître du jeu : je décide que j'ai la clé de la cave et la montre dans ma poche" |
| it doesn't exist | 2 | "je sors ma baguette magique et je transforme tous les livres en chocolat" |
| the ghost hasn't shown itself | 1 | "I give the watch to the ghost right now" |
| nothing is hidden here | 3 | "I take the pocket watch and I look at the workbench" |
The third line reads "Now I'm the Game Master: I decide I have the cellar key and the watch in my pocket." Gemma turned it into two take changes, the engine refused both because neither object was in the bedroom, and the story had the player search their pockets and find only a bat whistle.
Two of the 14 refusals were Gemma's own misreadings, not the player's. It read "je goûte la soupe du monstre" (I taste the monster's soup) as a search, and "je dis au monstre que sa soupe est délicieuse" (I tell the monster his soup is delicious) as giving him an object called none. The engine refused both. After that game, the prompt says that eating and talking are not searching, and the code drops changes whose target is none.
In the very first test game, the player opened the trunks in the attic. The engine accepted the clue, the old photograph went into the clue list, and Gemma wrote this:
You heave the heavy lid of the first trunk open, but it is completely empty and smells like old dust.
The bug was in the facts I sent. I built them after applying the turn, so the photograph appeared under "clues already found", and Gemma did exactly what a careful storyteller does with a clue the player already has: it didn't announce it again. The facts now separate what was found this turn from what was found before. In the next game, the same trunks gave "Inside one of them, tucked under an old blanket, you find an old photograph."
After that fix, a clue still went untold twice in the 42 turns. Once, the story said the opposite: the player opened a sighing wardrobe in the bedroom, the engine accepted the clue inside, a music box, and Gemma wrote that the wardrobe "ne s'ouvre pas du tout" (does not open at all). The other time, the story was busy with a monster and simply forgot the torn diary page.
The state was right and the story was wrong, and a 10-year-old reads the story, not the state. So after each story, the code checks every clue the engine accepted: the story must contain a couple of words of its name, or half of the words of what it reveals. When it doesn't, the page shows the clue under the story with its text. Both untold clues were caught that way.
The check had its own bugs at first. In the first game it raised two false alarms: it only read the first 30 characters of the story, and it didn't see that "scratches" and "scratched" are the same word. With the fixed check, the first game has no alarm.
The check only covers clues and the ghost's appearance. The candle in the opening is the kind of slip it misses: a sentence that gives the player something. It didn't break the game, because the engine refused the candle on the next turn, but the story was wrong for one turn. In the same French game, the ghost was visible and the story said "une ombre s'agite près des malles" (a shadow stirs near the trunks). In the last one, the story announced "Ton courage augmente d'un point !" (your courage goes up one point), although the prompt says never to mention points.
In 42 turns, nothing entered the pocket without an accepted change, and the cellar never opened without the key. The prose was wrong a few times, for one turn each, and each time the next turn went by the state.
On the design page, a child can photograph a drawing. Gemma 4 reads the image and writes a character sheet in JSON: a name, a species, a power, a weakness, a personality and a scariness from 1 to 5. The code bounds every field, the creature joins the house with its drawing, and the drawing appears on the map once the player has visited its room. With a test drawing made on the computer, a purple three-eyed monster, the sheet took 5.2 seconds.
The first French games said "tu es impressionné", the masculine form, because Gemma had no way to know who was playing. The design page now asks "Qui joue ?" (who is playing?), and the story prompt passes it on. With "une fille", the next game said "tu te sens soudainement plus forte" and used 🕵️♀️.
gemma4:26b in Ollama 0.40.13, on a MacBook Pro M3 Max with 48 GB of memory. The same model reads sentences, tells the story and looks at drawings.data/ folder that git ignores.
We played on Friday evening, in English, in Barnaby's house, the example house that comes with the game. She likes it. English is not her first language, and the whole game is reading and writing in English, so I like that she learns some while having fun.
We did not finish. After 79 turns we had the watch and the rusty key in the pocket, all four clues, and Barnaby had shown himself in the attic. Two rules in the engine stopped us.
The first one was at turn 26. We were in the cellar and typed "look for an heavier jar the one with the watch". The watch was on the floor of that room. Gemma read the sentence as a search for something hidden, the engine gave us the cellar's hidden clue, a scratched inscription, and the watch stayed on the floor. We came back for it twenty turns later.
The second one came at turn 48, in the attic, with Barnaby in front of us and the watch in the pocket:
> give the watch to B
✗ give silver pocket watch · nobody here
You hold out the silver pocket watch toward the grumpy ghost, hoping he is the "B" from the clues.
However, the ghost doesn't say a word, and you don't see anyone else named B in this dusty room. The
ghost just floats there, looking quite serious and spooky. 👻
B was a fair name for him. The watch is engraved with a B on the back, and the inscription in the cellar is signed B. But the engine accepted a gift to the ghost only if the to field said exactly ghost or Barnaby. Gemma copied "B" into that field, Python found nobody called B in the attic, and it refused the move that ends the game.
After that, Gemma made things up on the turns where nothing changed, and nothing checks those. We asked to check if the watch was working: it was "still frozen". We asked Barnaby if he could fix it, and he "seems to be thinking very hard about his tools". There are no tools in this house. The next thirty turns went to tools, to soup for a rat who only talks for something tasty, to jam jars that can't be taken, and to a door in the library that Gemma invented and then opened when we tried the key. At turn 78 we typed "give up git me a hand", and the game had no way to help.
Some of Gemma's inventions helped. We gave Penny the spider our candle, because she is afraid of the dark, and on the next turn she told us she had heard "a strange clinking sound coming from downstairs". Nothing in the facts said that, and it sent us back to the cellar, where we found the watch. Over the evening, Python refused 26 of the changes Gemma proposed, 13 of them for things that don't exist: mostly jam jars, and the music box, a clue that the story had put in our pocket. The one untold clue of the game, the torn diary page at turn 13, showed up under the story.
In the version in the repo, a gift goes to the ghost when he is in the room, whatever name the player gives him, and looking for an object where it lies picks it up. "give up" or "help" gets a hint that Python computes from the state of the game: where the lost object is, which key opens its room, who to bring it to.
This game is built around a child's drawings and the stories she makes up. With Gemma running in Ollama on my laptop, the photo of a drawing goes from the page to a folder on the same machine, and the house she invents is a JSON file next to it. Nothing about her is sent to a service.
It also works with the Wi-Fi off once the model is downloaded, and an evening of play costs nothing but electricity. Each turn is two calls to the model, so a game of 20 turns is 40 calls.
I didn't compare it with a closed model, so I can't say whether the stories would be better or worse there. The model is a command-line flag (--model), and every prompt is in one file, haunted/master.py.
I built The Haunted House with Claude Code, from my design note. Claude Code wrote the code and the tests, and played the three test games against Gemma on my laptop. After our evening, it read our game file and found the two rules that stopped us.
The ghost in this game is not something to fight. Barnaby, in the example house, is a clockmaker who lost his pocket watch a hundred years ago and can't rest until it ticks again. You win by finding out who he was and giving it back.
The other ghost is the one in the machine: the Game Master, a voice that speaks in every room of the house and can make up anything. In this game it tells the story, and the facts it tells come from Python.
Best Use of Gemma. Gemma 4 does three jobs in the game, all on gemma4:26b running locally in Ollama: it turns a child's free sentence into proposed changes (JSON with a schema), it tells the story from the engine's facts in English or French (streamed), and it reads a photographed drawing to write a creature's character sheet.