Skip to content

Repository files navigation

FF7 Chronicle Mobile

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).

What this project demonstrates

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

Architecture at a glance

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
Loading

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.


Engineering highlights

The parts worth reading the code for:

  • Streaming inference client (src/lib/api.js) — a hand-written Server-Sent Events parser over XMLHttpRequest: it slices new bytes on each readyState change, buffers partial SSE frames across chunks, extracts choices[].delta.content, accumulates the full message, and exposes an abort handle. Also has a blocking fetch client with AbortController timeouts 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 via stream_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.

One story turn, end to end

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
Loading

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.


Screenshots

Getting started


Enter your name
Pick a language, type your name, and enter the world as yourself

Choose your path
Start the main story or go straight to the group chat

Opening generation
The first scene is generated per run — every playthrough starts differently

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


Finale
A closing passage generated from the choices made during the run

Choices log
Every decision, in order

Novel view
Full scene-by-scene transcript: narration, dialogue, actions

Character read
A personality summary derived from how the player chose

Group chat


Daily life
Each character has their own ongoing situation

Relationship-aware
The relationship set for each character changes how the whole group responds

Persistent memory
Tell them something and they bring it up later

Memory panel
Inspect what the model is tracking, add an entry, or clear it

Features

Main story

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.

Group chat

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.

Bilingual

UI and prompts support English and Chinese, toggled on the start screen.

Standalone Android APK

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).


How the story engine works

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.


Agentic scene generation (tool use)

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:

  1. 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.
  2. validate_scene(scene) — runs the same validateScene guard 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.

Streaming

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.


Memory

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.


Evaluation, testing, and observability

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) through safeJsonObjectvalidateScene and asserts each is accepted or rejected as expected, and computes memory-retrieval recall@k over a labelled query set; this needs no key and runs in CI. Live (EVAL_RUNS=n) generates real scenes, reports schema-pass rate, and — with EVAL_JUDGE=1 — scores each on in-character and canon consistency via an LLM-as-judge. Results are written to eval/scorecard.json.
  • Schema validators (src/lib/schema.js) — validateScene / validateFinale are 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).

Delivery and network constraints

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.


Tech stack

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)

Project structure

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

Getting started

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 key
EXPO_PUBLIC_DEEPSEEK_API_KEY=sk-your-deepseek-key-here
EXPO_PUBLIC_USE_MOCK=false     # set true to run fully offline with canned responses

Run 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.

Building the Android APK

npm install -g eas-cli && eas login
eas build -p android --profile preview

EAS 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.


Roadmap

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.


Security

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.


License

Fan project. Final Fantasy VII belongs to Square Enix. Not affiliated with or endorsed by Square Enix.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages