An interactive, LLM-driven narrative app for iOS/Android and web. The player enters the Final Fantasy VII world as themselves and makes choices that a language model turns into an original, branching story in real time. A second mode simulates a group chat with six characters who reply in their own voices, remember earlier conversations, and adapt to the relationship the player sets with each of them.
It is built with React Native (Expo) and ships as a standalone Android APK and a web build. Every scene, character line, and memory summary is generated at runtime by DeepSeek V3 through an OpenAI-compatible API.
The engineering focus is the parts that make an LLM feature actually work in production: streaming inference, structured-output enforcement, defensive parsing, retrieval-backed memory, a tool-using agent, evaluation, observability, and a hardened backend — plus a delivery path that survives real-world network constraints (including mainland-China routing).
| Capability | Where it shows up |
|---|---|
| LLM application engineering | Streaming SSE client, strict structured-output prompting, defensive JSON repair |
| Retrieval / RAG | Top-k semantic memory behind a pluggable embedder, with a recall@k eval |
| Agents / tool use | Function-calling agent loop that self-corrects (lookup_canon, validate_scene) |
| Evaluation | Offline golden-set classification + recall@k; live schema-pass rate + LLM-as-judge |
| Observability | Per-call latency / tokens / cost / error & parse rates, on both client and server |
| Backend & infra | Python FastAPI proxy: key isolation, per-IP rate limiting, Docker |
| Testing & CI | 39 JS + 11 Python tests, GitHub Actions on every push |
The client owns orchestration, prompt construction, response parsing, state, and persistence.
Story generation runs through an agent-style engine built as a reason → act → observe loop:
the model reasons, and may call tools to ground itself in canon and validate its own draft
before committing. The default fast path is that same loop terminating on the first turn (the
model returns a final scene with no tool calls); the agent path exercises the full cycle. The
model is reached either directly (key on the client — the offline / mainland-China mode) or
through an optional Python proxy (server/) that holds the key, rate-limits per IP, and
records metrics.
flowchart TB
subgraph Client["Client — React Native (Expo)"]
UI["Screens · Story / Group Chat / Review"]
State["App.js — state · routing · write-through persistence"]
UI <--> State
end
subgraph Engine["Narrative engine — reason → act → observe loop (src/lib)"]
direction TB
Orch["Orchestrator<br/>prompt build + loop control<br/>(prompts.js · agent.js)"]
Reason["Reason<br/>LLM call (api.js)"]
Decide{"tool_calls?"}
Act["Act<br/>tool executor (agent.js)"]
Commit["Commit<br/>parse + validate (json.js · schema.js)"]
Orch --> Reason --> Decide
Decide -->|yes| Act
Act -.->|"observe: append tool results"| Reason
Decide -->|"no — final scene"| Commit
end
subgraph Grounding["Tools · memory · grounding"]
LC["lookup_canon → Canon KB (canon.js)"]
VS["validate_scene → Schema (schema.js)"]
Mem["Retrieval memory<br/>pluggable embedder, top-k (retrieval.js)"]
end
subgraph Access["Model access · observability"]
Proxy["Python proxy (server/)<br/>key · rate limit · metrics"]
Model[["DeepSeek V3<br/>OpenAI-compatible"]]
end
Persist[("AsyncStorage")]
State --> Orch
Orch -.->|"relevant notes"| Mem
Act --> LC
Act --> VS
Reason -->|"direct — China mode"| Model
Reason -.->|"if EXPO_PUBLIC_API_BASE_URL set"| Proxy
Proxy --> Model
Commit --> State
State --> Persist
How to read it. The engine is a reason → act → observe loop. Reason calls the model;
if it returns tool calls, Act runs them — lookup_canon pulls canon facts from the
knowledge base, validate_scene checks the draft against the schema — and the results are
appended back for the next turn (observe). When the model returns a final scene with no tool
calls, it goes straight to Commit (parse + validate). The default story path is that
loop terminating on turn one; the agent path (src/lib/agent.js) runs the full cycle so the
model grounds itself in canon and self-corrects before committing. Cross-screen state and
write-through persistence live in App.js; the model is reached directly or via the optional
Python proxy.
The parts worth reading the code for:
- Streaming inference client (
src/lib/api.js) — a hand-written Server-Sent Events parser overXMLHttpRequest: it slices new bytes on eachreadyStatechange, buffers partial SSE frames across chunks, extractschoices[].delta.content, accumulates the full message, and exposes an abort handle. Also has a blockingfetchclient withAbortControllertimeouts and a mock client for offline/no-key runs. - Structured-output enforcement — the story engine prompts the model for a strict JSON
object (location, narration, speaker, dialogue, cast, four options). Because models return
markdown fences and truncated JSON,
safeJsonObject()(src/lib/json.js) strips fences, walks braces to isolate the outermost object, and repairs truncation by counting unbalanced brackets/arrays and closing them. On total failure the UI degrades to a retry instead of crashing. - Two-tier, retrieval-augmented memory — short-term is a sliding window of the last ~12
messages; long-term is an LLM-as-summarizer pass that compresses the transcript into
per-character notes and persists them. Rather than dumping recent notes into every prompt,
the group chat embeds them and retrieves the top-k most relevant to the current message
(
src/lib/retrieval.js) — a proper retrieval-augmented pattern behind a pluggable embedder interface. A memory panel lets the user inspect, hand-edit, or clear what the model tracks. - Tool-using agent loop (
src/lib/agent.js) — an optional generation path where the model is given function-calling tools (lookup_canon,validate_scene) and runs an observe→act loop: look up canon facts, draft a scene, validate the draft, self-correct, then commit. The loop is provider-agnostic (injectable model client) and bounded by a step limit. - Stateful prompt engineering — story prompts are assembled from arc position (early/mid/late/climax instructions), the current cast, and canon-relationship constraints so the narrative escalates coherently and stays in character. Group-chat prompts are conditioned on a per-character relationship setting (stranger/friend/partner/lover/enemy).
- Defensive response handling — group-chat replies are parsed line-by-line, speaker names
normalized across English/Chinese aliases, and stage directions (
*...*,(...)) stripped, because the model occasionally ignores the "dialogue only" instruction. - Evaluation harness (
eval/) — an offline regression suite that replays golden model outputs through the parse → validate pipeline and asserts each is classified correctly, plus a live mode that generates real scenes, measures schema-pass rate, and runs an LLM-as-judge on in-character and canon consistency. Emits a scorecard (npm run eval). - Observability (
src/lib/metrics.js) — every model call records latency, prompt/completion tokens, estimated cost, and error/parse outcome; a rollup exposes p50/p95 latency, error rate, and spend. Token usage is captured from the stream viastream_options.include_usage. - Tests + CI — 39 JS unit tests (Node's built-in runner) and 11 Python tests (pytest) cover the parser, schema validators, group parsers, metrics, retrieval, the agent loop and canon KB, the rate limiter, and the proxy endpoints; GitHub Actions runs both suites and the offline eval on every push.
- Backend proxy (
server/, Python / FastAPI) — an optional server that holds the API key, rate-limits per IP with a token bucket, records server-side metrics, and proxies blocking and SSE-streaming completions. The client ships no secret when pointed at it; it falls back to direct calls when not. - Cross-platform delivery — one codebase builds to a standalone Android APK via EAS and to
a web bundle via
react-native-web.
sequenceDiagram
participant U as Player
participant S as StoryScreen
participant P as prompts.js
participant A as api.js (SSE)
participant D as DeepSeek V3
participant J as json.js
U->>S: choose option / free-text action
S->>S: phase = loading
S->>P: buildStoryPrompt(cast, arc, choice)
P-->>S: system prompt
S->>A: streamChatAPI(prompt)
A->>D: POST /chat/completions (stream: true)
D-->>A: SSE deltas
A-->>S: onDone(full response)
S->>J: safeJsonObject(full response)
J-->>S: scene { narration, speaker, dialogue, options }
S->>S: normalizeScene → typewriter narration → dialogue → options
S-->>U: render scene + choices
normalizeScene is the guardrail between model output and UI: it validates the cast against
the allowed roster, guarantees the player is present, enforces a minimum cast size that grows
as the story progresses, and always keeps exactly one free-input option.
Getting started
Main story
![]() Narration Each scene opens with typewriter narration; portraits show who is present |
![]() Choose your action Three model-written options plus a free-text slot for anything else |
Finale and review
Group chat
Each scene is one API call returning a fixed-schema JSON object. The engine tracks arc position across up to nine scenes and adjusts tone — setup early, rising stakes later, a consequence-bearing finale at the end. Canon relationships are constrained via the prompt so characters stay in character. After the finale, a review screen shows a full transcript, an ordered log of every choice, and a personality read derived from those choices.
Before entering, the player sets a relationship type per character (stranger / friend / partner / lover / enemy) that stays in the prompt for the session. Not everyone replies to every message — the model picks the natural responders and returns 1–4 lines. History is persisted locally, and a long-term memory pass summarizes the conversation into per-character notes every few messages.
UI and prompts support English and Chinese, toggled on the start screen.
Builds via EAS with no Expo Go needed on the user's device. Works in mainland China because model calls go directly to DeepSeek's China-hosted infrastructure (see Delivery and network constraints below).
Each scene is a single request. The prompt asks the model for a strict JSON object:
{
"location": "Seventh Heaven",
"narration": "Cloud cleans his sword in silence as neon light bleeds through the rain.",
"speaker": "tifa",
"dialogue": "If we don't decide now, Shinra decides for us.",
"present": ["player", "cloud", "tifa"],
"options": [
{ "text": "Press Cloud for the truth", "target": "joy_action" },
{ "text": "Turn to reassure Tifa", "target": "joy_action" },
{ "text": "Point out the tampered device on the bar", "target": "joy_action" },
{ "text": "Say exactly what you want to do", "target": "free" }
],
"readyForFinale": false,
"finaleChoiceText": ""
}safeJsonObject() handles the cases models actually produce: markdown fences around the JSON,
extra prose before or after the object, and responses truncated mid-object by the token limit.
It strips fences, isolates the outermost braces, and — if the object is unterminated — repairs
it with a string-aware bracket stack that closes open arrays and objects in the correct nesting
order (a naive closer gets the order wrong when an array is truncated inside an object). If
parsing still fails, the screen surfaces an error and lets the player retry instead of crashing.
A bug worth documenting: early builds passed the full conversation history into every
continuation call. After a few scenes the accumulating Scene: ... / [cloud]: ... transcript
confused the model and it began returning prose instead of JSON. The fix: the system prompt
already carries the needed context, so continuation calls now send a bare "Continue" message.
This is a small, concrete lesson in context management — more history is not always better.
The default story path is a single call plus defensive parsing. There is also an optional
agent path (src/lib/agent.js) that trades latency for self-correction and grounding.
The model is given two function-calling tools and runs an observe→act loop:
lookup_canon(characters)— retrieves traits and relationship guidance for the present cast from a centralized canon knowledge base (src/lib/canon.js), so the model writes with the facts in front of it rather than from memory.validate_scene(scene)— runs the samevalidateSceneguard the harness uses and returns{ ok, errors }, so the model can catch its own schema violations and fix them before committing.
The loop executes each tool call, feeds the result back, and repeats until the model returns a
final scene with no tool calls; a step limit bounds it, and a final validation guards the
output. The loop is provider-agnostic — it takes an injected runModel({ system, messages, tools }), which is a scripted stub in tests and createDeepSeekAgentRunner() in production —
so the whole thing is unit-tested with no network. Run it live against the model with
EVAL_AGENT=1 npm run eval.
Building the canon KB surfaced a real bug in the previous inline version: three of the six
relationship entries were keyed unsorted (e.g. zack:aerith) while the lookup sorts its key,
so that guidance never matched and silently never reached the prompt. The centralized version
keys everything sorted and a unit test enforces it.
streamChatAPI opens an XMLHttpRequest with stream: true and parses the Server-Sent
Events response incrementally: on each readyState change it takes only the newly arrived
bytes, buffers any partial SSE frame across chunks, parses complete data: lines, and
accumulates delta.content into the full message while exposing an abort handle. HTTP and
network errors are surfaced with the API's own error message where available. The blocking
fetch client (callChatAPI) is retained for the group chat, with AbortController-based
60s timeouts. A mock client returns canned scenes when no key is set, so the UI is fully
runnable offline.
Two tiers. Short-term is the last ~12 messages, formatted and included in every prompt.
Long-term fires roughly every N messages: a separate call summarizes the recent transcript
into per-subject notes (group, player, or a specific character), which are persisted to
AsyncStorage and reloaded on the next session.
Retrieval, not recency. Once there are enough long-term notes, the prompt no longer gets the
most recent ones — it gets the most relevant. src/lib/retrieval.js embeds each note and
returns the top-k by cosine similarity to the current message, behind a pluggable { name, embed }
embedder interface. The default embedder is a dependency-free bilingual lexical model (term
frequency over latin words and CJK character bigrams, L2-normalized) so it runs on-device and in
CI at zero cost; swapping in a hosted or on-device semantic model is a one-line change. The
harness's recall@k metric exists to measure exactly that trade-off.
The memory panel (brain icon) shows all entries grouped by subject and supports manual add, per-entry delete, and clear-all. Auto-generated and hand-added entries are tracked separately so a refresh never wipes the user's manual notes.
Because model output is probabilistic, the project treats "does the response conform" as a measurable property rather than a hope.
Latest run (eval/scorecard.json):
| Metric | Result |
|---|---|
| Offline parse/schema classification | 7/7 (100%) |
| Group-chat parser | 3/3 |
Memory recall@3 |
1.0 |
| Live schema-pass rate (3 generated scenes) | 3/3 (100%) |
| LLM-as-judge — in-character / canon | 4.33 / 4.33 (out of 5) |
| Agent — valid scene committed | in 4 steps (looked up canon, validated, self-corrected) |
| Live latency p50 / p95 | 2.4s / 4.2s |
| Error rate · tokens · cost (10 live calls) | 0% · ~7k tokens · ~$0.003 |
- Eval harness (
eval/run.mjs,npm run eval) — runs in two modes. Offline replays a set of golden model outputs (clean, fenced, prose-wrapped, truncated, and deliberately malformed) throughsafeJsonObject→validateSceneand asserts each is accepted or rejected as expected, and computes memory-retrievalrecall@kover a labelled query set; this needs no key and runs in CI. Live (EVAL_RUNS=n) generates real scenes, reports schema-pass rate, and — withEVAL_JUDGE=1— scores each on in-character and canon consistency via an LLM-as-judge. Results are written toeval/scorecard.json. - Schema validators (
src/lib/schema.js) —validateScene/validateFinaleare the single source of truth for well-formedness (required keys, cast on the allowed roster, player present, exactly one free option, speaker is a character). Shared by the harness and available as an in-app guard. - Metrics (
src/lib/metrics.js) — latency, tokens, cost, and error/parse rates per call, rolled up to p50/p95 latency and total spend. - Unit tests (
tests/,npm test) — 39 cases over the pure logic, including a regression test for a truncation-repair bug found while building the harness (the old repair appended array/object closers in the wrong nesting order for scenes truncated mid-array).
Getting this reachable from mainland China drove the current architecture.
The web version runs on Vercel and calls Anthropic's Claude API — fine outside China. The
mobile app first pointed at the same Vercel endpoint, but Vercel is blocked by the Great
Firewall, so Chinese users hit an immediate connection error. Moving the proxy to a Cloudflare
Worker didn't help either — *.workers.dev subdomains are commonly blocked as well.
What worked was removing the proxy entirely and calling DeepSeek directly. DeepSeek is China-hosted and consistently reachable from Chinese networks, and its OpenAI-compatible API was a drop-in. DeepSeek V3 also handles the Chinese-language prompts well.
fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` },
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'system', content: systemPrompt }, ...messages],
max_tokens: 2000,
}),
});One deployment gotcha: the first APK built this way still fell back to mock responses. EAS
cloud builds don't upload the local .env (it's gitignored), so the key never reached the
bundle — it has to be declared in the env block of eas.json. This direct-call design is a
deliberate trade-off documented under Security below.
| Layer | Technology |
|---|---|
| Mobile framework | Expo SDK 54 / React Native 0.81 / React 19 |
| Language | JavaScript |
| Model | DeepSeek V3 (deepseek-chat), OpenAI-compatible API |
| Streaming | Server-Sent Events over XMLHttpRequest |
| Local persistence | AsyncStorage |
| Build / distribution | EAS Build (standalone Android APK) |
| Web target | Expo Web (react-native-web) |
| API proxy (optional) | Python / FastAPI + httpx, Docker (server/) |
| Web backend (separate) | Next.js on Vercel (Claude API) |
ff7-chronicle-mobile-starter/
├── App.js # Orchestration: cross-screen state, routing, persistence
├── app.json / eas.json # Expo + EAS build config
├── .env / .env.example # Local env (key + mock toggle)
│
├── src/
│ ├── screens/
│ │ ├── StartScreen.js # Name entry, language toggle
│ │ ├── StoryScreen.js # Story engine + narrative state machine
│ │ ├── StoryReviewScreen.js # Transcript, choice log, character read
│ │ ├── GroupSetupScreen.js # Per-character relationship config
│ │ └── GroupChatScreen.js # Group chat + memory panel
│ │
│ ├── components/ # Avatar, buttons, panels, loader
│ │
│ ├── lib/
│ │ ├── api.js # Model clients: streaming, blocking, mock (+ metrics)
│ │ ├── prompts.js # System-prompt builders (story + group chat)
│ │ ├── json.js # safeJsonObject(): defensive JSON repair
│ │ ├── schema.js # validateScene / validateFinale: structured-output validators
│ │ ├── metrics.js # Observability: latency, tokens, cost, error/parse rates
│ │ ├── retrieval.js # Semantic memory: pluggable embedder, cosine top-k retrieval
│ │ ├── agent.js # Tool-using agent loop (lookup_canon, validate_scene)
│ │ ├── canon.js # FF7 canon KB: traits + relationships (also backs the agent)
│ │ ├── groupParse.js # Pure group-chat output parsers (unit-tested)
│ │ ├── storage.js # AsyncStorage wrappers
│ │ └── characters.js # Roster: ids, bilingual names, colors
│ │
│ └── theme/theme.js # Color palette
│
├── eval/
│ ├── run.mjs # Eval harness: offline classification + live + judge
│ ├── fixtures.mjs # Golden model-output fixtures
│ └── scorecard.json # Latest eval results (generated)
│
├── tests/ # JS unit tests (node:test): json, schema, groupParse, metrics, retrieval
├── .github/workflows/ci.yml # CI: tests + offline eval on every push
│
├── server/ # Optional Python API proxy (FastAPI)
│ ├── app/
│ │ ├── main.py # Endpoints: /api/chat, /api/chat/stream, /healthz, /metrics
│ │ ├── deepseek.py # Upstream client (only place the key is used)
│ │ ├── rate_limit.py # Per-IP token bucket
│ │ ├── metrics.py # Server-side observability
│ │ └── config.py # Env-driven settings
│ ├── tests/ # pytest: rate limiter, metrics, API (mocked upstream)
│ └── Dockerfile
│
└── docs/
├── MOBILE_MIGRATION_PLAN.md
└── AI_ENGINEER_SIGNAL_PLAN.md # Engineering plan: eval / RAG / backend hardening
Prerequisites: Node.js 18+, a free Expo account, and a DeepSeek API key.
git clone https://github.com/js3888-shunshun/ff7-chronicle-mobile
cd ff7-chronicle-mobile
npm install
cp .env.example .env # then set your keyEXPO_PUBLIC_DEEPSEEK_API_KEY=sk-your-deepseek-key-here
EXPO_PUBLIC_USE_MOCK=false # set true to run fully offline with canned responsesRun on web: npx expo start --web → opens at http://localhost:8081.
Run on a device: npx expo start --tunnel, then scan the QR with Expo Go. The
--tunnel flag works across networks, so the phone need not share Wi-Fi with the computer.
npm install -g eas-cli && eas login
eas build -p android --profile previewEAS cloud builds do not read your local .env, so the key must be declared in the env block
of the build profile in eas.json; otherwise the APK builds but silently runs in mock mode.
The build takes ~10–15 minutes and produces a download link for the .apk.
Product roadmap (backend, accounts, credits, performance, store submission) is in
ROADMAP.md. A separate engineering plan for raising the production/AI-systems
maturity of the project is in
docs/AI_ENGINEER_SIGNAL_PLAN.md — the evaluation harness,
observability, schema validators, retrieval-backed memory, the server-side key proxy, tests, and
CI from that plan are already in place.
Developer commands: npm test (JS unit tests), npm run eval (offline eval; add EVAL_RUNS=n
for live generation, EVAL_JUDGE=1 for the LLM-as-judge, EVAL_AGENT=1 for a live agent run),
and cd server && pytest (proxy tests). Proxy setup is in server/README.md.
The app supports two paths. In direct mode, the DeepSeek key is bundled into the client as an
EXPO_PUBLIC_* variable and is extractable from the APK — an accepted trade-off for a demo, and
what makes the proxy-free path work from within China. In proxy mode (set
EXPO_PUBLIC_API_BASE_URL to the server/ deployment), the client ships no secret at all: the
key lives on the server, which also rate-limits per IP and meters usage. Proxy mode is the path
for public distribution; direct mode remains for offline and mainland-China use.
Fan project. Final Fantasy VII belongs to Square Enix. Not affiliated with or endorsed by Square Enix.












