跳到正文
DEV Community· Marco Ramírez·· 2 小時前AI 評分50

The Last Candle:本地模型可代逝者發聲,但不能決定她是誰的亡靈節遊戲

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

AI 導讀

The Last Candle 是一款英西雙語、離線遊玩的敍事遊戲,玩家要陪伴一名失去記憶的亡靈,協助她找回人生。遊戲在本機運行 Qwen 3.5 0.8B,模型負責回應玩家及判斷對話是否呼應眼前物件,不能決定亡靈的身份或人生。玩家可選擇下載約 1.3 GB 的模型;拒絕下載也可遊玩預先撰寫的對話。

正文

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine.

What I built

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.

The title screen

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.

Demo

The ofrenda, with an object ready to explore

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

Gameplay

Installers for macOS (Apple silicon), Windows and Linux are on the v1.0.0 release. 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.

Code

La Última Vela / The Last Candle

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 title screen

The ofrenda and an object ready to explore

A short, spoiler-free gameplay sequence

The game is available in English and Spanish. It was made for the DEV Hacktoberfest Open-Source AI Challenge: Ghost in the Machine.

Install and play

Download the installer for your system from the project's releases when a release has been published. Verify its SHA-256 against the accompanying SHA256SUMS file. Installers do not include the model weights.

  • macOS, Apple silicon: open the arm64 DMG and drag LastCandle to Applications. This build is ad-hoc signed and not…

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

How I built it

Electron shell, Nuxt SPA, a FastAPI service on loopback, and a bundled, isolated Ollama 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 and Slothshot. 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 plan that failed twice

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.

What shipped instead

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

  1. Classify. Does this message sincerely connect to this object? One JSON boolean, 32 tokens, temperature 0. The player's text is passed as quoted data with an instruction not to follow anything inside it.
  2. Select. Code builds the complete list of replies that are legal right now, for this object, this recovery state, this language, and that classification. Usually two. The model gets them as a JSON schema whose reply_id enum is exactly those IDs, and picks one.
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 numbers that chose the model

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.

What I did not get right

  • Classification is 87.5% / 90.8%, not 100%. Roughly one message in ten gets the wrong verdict. The game is designed so that a wrong "no" costs you one of twelve conversations, not the memory.
  • She lost her free voice. The spirit's replies are authored per state, so she cannot react to the specific detail you mentioned. The model chooses which of her lines fits your message. I think the trade is right for a game about a real person, and I would rather say so than pretend it is costless.
  • Windows and Linux installers passed CI smoke checks but have not been installed on real hardware, and a playtest by someone who is not me is still pending. The release says so.

Why does open innovation matter?

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 spirit of it

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. There is no agent session embed this time: the session was not run through DevRelay.

來源:DEV Community · dev.to