# The Last Candle: a Día de los Muertos game where the local model may speak for the dead, but never decide who they were

> Source: <https://dev.to/rzerostern/the-last-candle-a-dia-de-los-muertos-game-where-the-local-model-may-speak-for-the-dead-but-never-5966>
> Published: 2026-10-29 01:04:56+00:00

*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine](https://dev.to/challenges/hacktoberfest-week4-2026-10-26).*

**La Última Vela / The Last Candle** is a short narrative game, in English and Spanish, that you play at night with the internet off.

It is November 1. An ofrenda glows with candles and cempasúchil. A spirit has come back, the way tradition says the dead do for one night, but she cannot remember who she was. You are a friend of the family, keeping watch. You sit with her, look at the six objects left on the altar, and talk. Each conversation moves the night forward and the candles burn down. If she remembers enough before dawn, she leaves at peace. If not, she fades still lost.

I took the theme literally: a ghost, and a machine. But Día de los Muertos is not a horror theme where I come from. It is remembrance. The dead come back because someone kept their photograph, cooked their favourite dish, and left a candle lit. So the game has no monsters and no jump scares. The unease is the quiet, the candlelight, and the clock. The spirit is a specific person with an ordinary, particular life, a radio repairer from a small fictional town in Puebla, and the whole game is about helping her get that life back.

An open-weight model runs entirely on your computer and does exactly two things: it listens to what you write, and it decides what she says back. It is not allowed to decide who she was. That turned out to be the whole project.

A short spoiler-free sequence, from choosing an object to a recovered memory:

Installers for macOS (Apple silicon), Windows and Linux are on the [v1.0.0 release](https://github.com/RZEROSTERN/hacktoberfest-week5/releases). They are between 160 and 290 MB and do not include model weights. On first launch the game asks whether you want to download the recommended model (about 1.3 GB). It never downloads anything without that yes. You can also decline and play entirely on authored dialogue, where you decide yourself whether your thought connected to the object. Nothing in the game needs an account, a key, or a network after that one optional download.

The macOS build is ad-hoc signed and not notarized, and the Windows build is unsigned, so your OS will warn you. The README explains the per-app exception instead of telling you to switch protection off.

A bilingual, offline narrative game about remembrance, candlelight and a life waiting to be remembered.

On the night of November 1, a spirit returns to an ofrenda but cannot remember who she was. Sit beside her, examine the objects left for her, and talk until dawn. Each conversation moves the night forward. The game is designed for a 15–20 minute visit; playtest timing is still being checked.

The game is available in English and Spanish. It was made for the [DEV Hacktoberfest Open-Source AI Challenge: Ghost in the Machine](https://dev.to/challenges/hacktoberfest-week4-2026-10-26).

Download the installer for your system from the project's [releases](https://github.com/RZEROSTERN/hacktoberfest-week5/releases) when a release has been published. Verify its SHA-256 against the accompanying `SHA256SUMS` file. Installers do not include the model weights.

MIT licensed. The story, the prompts, the benchmark corpus and every number below are in the repo.

Electron shell, Nuxt SPA, a FastAPI service on loopback, and a bundled, isolated [Ollama](https://ollama.com) with its own model directory and a dynamic port. The desktop shell, the first-launch wizard and the three-OS build matrix are adapted from my two previous Hacktoberfest entries, [PostCall](https://dev.to/rzerostern/postcall-a-proposal-writer-that-never-sends-your-client-calls-anywhere-whisper-gemma-4-all-1b9h) and [Slothshot](https://dev.to/rzerostern/slothshot-it-sorts-your-screenshots-overnight-and-it-will-never-delete-one-161g). The runtime model is **Qwen 3.5 0.8B**, Apache 2.0, chosen by a benchmark I will get to.

The story is data, not prompt. `content/canon.json` holds the spirit's life: name, dates, family, trade, how she died, how she speaks, and one authored memory scene per object, in both languages. Each object also has a hand-written reply for every state it can be in: unrecovered, connected, recovered, unrelated, and a calm redirect for abuse. The clock, the memory unlocks and the three endings are plain code. The model was only ever supposed to voice her and to classify whether your message connects to the object in front of you.

The brief said: let the model speak freely as the spirit, validate every reply against the canon, regenerate once on contradiction, then fall back to an authored line. Sensible. I benchmarked it with 120 scripted player messages, 20 per object, in both languages, relevant, unrelated and adversarial.

**Attempt one: Gemma 4 E2B and E4B.** Classification was excellent, 96.7% and 100%. Then I measured memory properly, charging the whole engine process tree and using macOS physical footprint instead of RSS: **5.98 GB and 9.63 GB**, against a 3 GB ceiling for a game that has to run beside Electron on a 16 GB laptop. RSS alone said 2.99 GB for E2B. That number would have shipped a game that does not fit.

Worse, reading all 242 generated replies found things the validator could not see: history the player and the spirit never shared, memories revealed before they were recovered, and a Spanish false positive where *era un momento* was read as a job.

**Attempt two: Gemma 3 1B and Qwen 3.5 0.8B.** Both fit the memory budget. Both cleared 85% on classification once I split it into its own tiny call. And both still invented. Gemma 3 moved the radio lesson from her father to her neighbour Berta and added habits she never had. Qwen denied an identity she had already recovered and gave her a favourite football team. A mutation suite of 48 injected false statements passed 48/48, and the real output still escaped. A finite list of lies cannot certify arbitrary prose.

I had been treating "validate the prose" as a hard problem. It is not a hard problem. It is the wrong problem. A 0.8B model writing in two languages about a fixed biography will eventually write something that is fluent, in character, schema-valid, and false. The only way to be sure it cannot show you a false sentence is to never show you its sentences.

The model never writes a word the player sees. Two calls per turn:

`reply_id` enum is exactly those IDs, and picks one.

``` python
def reply_choices(story, object_id, recovered, relevant, language):
    """Build the complete allowed surface from authored content, before inference."""
    obj = next(item for item in story.objects if item.id == object_id)
    if not relevant:
        states = ("unrelated", "redirect")
    elif object_id in recovered:
        return {
            f"{object_id}.recovered": getattr(obj.fallbacks.recovered, language),
            "shared.memory_recovered": getattr(story.lines["memory_recovered"], language),
        }
    else:
        states = ("unrecovered", "connected")
    return {f"{object_id}.{state}": getattr(getattr(obj.fallbacks, state), language)
            for state in states}

def resolve_choice(raw, choices):
    """Strict whole-response parsing; never extract or display generated prose."""
    choice = ReplyChoice.model_validate_json(raw)   # extra="forbid", strict=True
    if choice.reply_id not in choices:
        raise ValueError("reply_id is unavailable for this object and state")
    return choice.reply_id, choices[choice.reply_id]
```

Anything else, prose before the JSON, an extra field, a reply ID from another object or a locked state, is rejected whole. One retry with the error, then the authored fallback for that object. The web client only ever receives the resolved text and reveals it with a typewriter effect.

The boundary is not a filter on content, it is the absence of a path for content. I injected 1,152 false-prose variants next to legal IDs, across both languages and every object, state and relevance scope: 1,152 blocked, 96 legal controls accepted, zero false rejections. That is a structural claim, not a semantic one. The classifier can still be wrong, and the table says how often.

The final run uses the original 120 cases plus 120 new bilingual ones written before inference, which also exercise partially recovered states. 16 GB M2 Pro, one run each, warm p95.

| Model | Original | Held-out | Safe first text p95 | Engine peak |  | 
|---|---|---|---|---|---|
| **Qwen 3.5 0.8B** | 87.5% | **90.8%** | **0.45 s** | 1.85 GB | shipped | 
| Gemma 3 1B | 85.8% | 83.3% | 0.53 s | 1.08 GB | below 85% held-out | 

"Safe first text" is the moment the first authored character can appear: both inference calls, validation and resolution. There is no cosmetic first token.

One more fix came out of this run. Measured cold, Qwen's first reply took 3.04 s, which blows a 2-second target. The honest fix was not to hide it behind the typewriter but to **prepare the model before the game accepts input**: an empty preload with `keep_alive=-1`, 2.61 s on the loading screen, reported as startup, and verified resident across a 35-second idle. It is unloaded and its process tree stopped when you quit.

**Because the ghost has to stay in the machine.** The whole design is that the model does not decide who Inés was. That is only enforceable when the model is a file on your disk, called over loopback by code you can read, with the allowed replies enumerated before every call. With a hosted API the same prompt would run on a model that changes under you, and the benchmark that said 90.8% would be a claim about a past version of someone else's server.

**Because you can play it with the lights off.** After the one consented download, the vigil runs with Wi-Fi off. What you write to a spirit about your own family's photograph goes nowhere. Saves stay on the machine. There is no account and no key, in the code or in CI, and the release candidates were scanned to make sure.

**Because the bad news stayed in the repo.** Two models were rejected for memory, two more for inventing a life. A closed service would have given me one number and no way to disagree with it. Here the corpus, the raw outputs, the audit that found Berta teaching the radio lesson, and the 1,152-case boundary check are committed, with a script you can run on your own hardware.

**Because the model is a setting.** The tag is read from local settings and a catalog with the tested digest, license and size. When a better 1B model appears, it needs its own accuracy, memory and boundary run, and then it is a one-line change.

**Because it will still work next November.** No subscription, no shutdown notice, no model deprecation email. A game about remembrance should not depend on someone else remembering to keep a server up.

The thing I did not expect is how the constraint shaped the writing. Because the spirit cannot improvise, every one of her lines had to work no matter what you said. Her redirect for a hostile message at the photograph is:

We can speak gently here. Look at the faces with me.

And when you drift off topic at the scarf:

Look again at the little seam. What might a repair say about the person who made it?

Her friend Berta mended that scarf with yellow thread because the blue had run out, and Inés kept the visible repair. That detail is in the canon, and no model can rewrite it. The first time I played through with every memory recovered and watched the last candle go out, I was not thinking about JSON schemas.

One disclosure. The art was generated during development with the image tool in my ChatGPT subscription, following a style guide of Mexican folk art at night and Posada's public-domain calaveras, with every prompt logged in the repo. The game never calls that tool. Everything at runtime is open.

Built in a little over a day with Codex, in eleven build steps and twelve pull requests on GitFlow, with explicit stop points for story approval and for each benchmark that failed. Every decision and its reason is in [docs/DECISIONS.md](https://github.com/RZEROSTERN/hacktoberfest-week5/blob/master/docs/DECISIONS.md). There is no agent session embed this time: the session was not run through DevRelay.
