# Project export: TaleGate Club

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Turn your nightly reading habit into a shared roleplaying adventure. TaleGate Club delivers a daily chapter to you and your friends whose decisions shape the fate of the story.
- Devpost: https://devpost.com/software/talegate-club
- GitHub: https://github.com/jnoahbaier/berkeleyhackathon26
- Video: https://www.youtube.com/embed/Ny92OLsZ7is?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — noahbaier (2 commits), Cursor (2 commits), Noah Baier (1 commits)

## Devpost submission (written by the team)

### Inspiration

Long-distance friendships drift. Group chats go quiet. Plans to "catch up" never happen. We wanted to build something that gives friends a reason to reconnect throughout the week. Not a video call, not a social feed, but a shared ritual: reading the same story together, each of you living inside it as a character. We drew inspiration from three places: the intimacy of a book club (a reason to gather regularly around something meaningful), the character embodiment of D&D (everyone has skin in the game), and the serialized anticipation of episodic TV (you always want to know what happens next). TaleGate Club is what happens when you blend all three.

### What it does

TaleGate Club lets 2-4 friends form a "guild" and embark on a month-long collaborative story together. Here's how it works: Guild setup: One person creates a guild and invites up to 3 friends via link. Onboarding: Each player enters their favorite books, authors, or genres. We use these preferences to seed a story tailored to the group's collective taste. Character creation: Each player is assigned a character. Nightly chapters: Every evening at 6pm, a new chapter drops. Everyone reads the same story. Per-character decisions: Each chapter ends with a central choice for your character that'll be woven into a future chapter. Story convergence: Characters may start in separate situations but gradually meet, with their choices creating real consequences for each other: betrayal, alliance, or sacrifice. Season wrap: After ~30 chapters, the story ends with an epilogue, credits listing who played which character, and their playstyles.

### How we built it

The story is guided by a structured knowledge graph that maintains continuity (tracking characters, relationships, locations, and unresolved threads) so that we never forget important story details. TaleGate Club is built as a mobile-first web app with a lightweight iOS shell for device demo. Frontend: Next.js with a clean reading-focused UI; dark/light mode; mobile-first. Backend: Node/Express API with Postgres for persistent story state. AI layer: LLM-powered chapter generation using structured system prompts and a per-campaign "story bible" (JSON knowledge graph) to maintain long-term continuity. Decision system: Per-character choices stored with timestamps; a background worker auto-resolves expired decision windows. Kindle sync: Chapters delivered via Send-to-Kindle personal document email. Audio: TTS-powered narration with a single voice narrator and an in-app sleep timer. Story archetypes: 10 hand-crafted story skeletons (premise, tone, key beats) that guide the AI — so every campaign feels curated, not like AI slop.

### Challenges we ran into

Multi-character narrative coherence: Maintaining a consistent world state across 4 independent character arcs over 30 chapters was our hardest problem. We solved it with a structured story bible that is updated after every chapter — characters, relationships, locations, open threads — fed as context into every generation call. Balancing player agency with story structure: Letting players make real choices while keeping the story coherent and satisfying required careful prompt engineering. We constrain choices to 2–3 pre-authored options per character per chapter, which also keeps content within our PG-13 safety guidelines. Async multiplayer pacing: Four people in different time zones reading at different speeds is hard to coordinate. We solved this with a fixed daily drop window, a 24-hour decision deadline, and auto-decision logic that keeps the story moving without punishing the group for one person's absence. Kindle interactivity limits: Kindle is read-only; you can't do real-time branching on-device. Our solution cleanly separates reading (Kindle) from deciding (app), which actually reinforced the ritual: read at night, choose during the day.

### What we learned

Shared rituals are more powerful than social features. The 10pm chapter drop creates more engagement than any notification or leaderboard could. Constraining AI output (via archetypes, choices, knowledge graphs) produces better stories than unconstrained freeform generation. The emotional hook of TaleGate Club isn't the AI — it's your friends. The AI is just the medium.

## README (from the GitHub repository)

# Talegate

**A shared bedtime story for friends who live far apart.** → [talegate.club](https://talegate.club)

2-4 friends form a *guild*, read the same chapter each night, and each make a
choice for their own character. An LLM (Anthropic Claude) weaves everyone's
divergent choices into a single shared next chapter. Over ~a month the story
grows together so that when friends text or meet, they're all on the same page —
literally. Inspired by book clubs, Spotify Blend, and choose-your-own-adventure.

Built for the Berkeley AI Hackathon, Summer 2026.

---

## How it works

```
Story bible + world state (per guild)
        │
        ▼
  Tonight's chapter  ──►  each player makes ONE choice for their character
        │                         │
        │                         ▼
        │             all submitted OR deadline (auto-fills stragglers)
        │                         │
        ▼                         ▼
  Claude merges every choice into the next shared chapter + updates world state
```

- **One shared chapter per night.** Length scales with the player count, so
  everyone reads roughly the same amount and stays aligned.
- **Stay-in-sync mechanism.** The next chapter is gated on everyone submitting,
  or a nightly deadline. Anyone who didn't choose gets a sensible default so the
  guild never drifts apart — no one is left needing a recap.
- **Personalized worlds.** The setting/archetype and each character are seeded
  from the players' profiles (favorite books, movies, games, hobbies).
- **Demo fast-forward.** An "Advance the night" button resolves the chapter
  immediately so you can show several nights in a 3-minute demo.

## Repo layout

| Path        | What it is                                                            |
| ----------- | --------------------------------------------------------------------- |
| `server/`   | Node + Express + Socket.IO backend, Claude integration, JSON store    |
| `mobile/`   | Expo (React Native + TypeScript + Expo Router) app                    |

### Architecture note

The plan called for Supabase. For a live two-phone hackathon demo this backend
instead runs **locally** (Express + Socket.IO + a JSON-file store) so it boots
with zero cloud setup — both phones already need to be on your laptop's network
for Expo anyway. The data model (`users`, `profiles`, `guilds`,
`guild_members`, `chapters`, `choices`) and the two AI flows (`generate-bible`,
`generate-chapter`) mirror the plan exactly, so Supabase/Postgres can be swapped
in later without touching the app.

---

## Quick start

### 1. Backend

```bash
cd server
npm install
cp .env.example .env      # optional: add ANTHROPIC_API_KEY for real stories
npm start                 # http://localhost:4000
```

Without an `ANTHROPIC_API_KEY` the server runs in **mock mode**: fully
functional, deterministic placeholder stories that still react to each player's
choices. Add a key (and optionally `ANTHROPIC_MODEL`, default
`claude-sonnet-4-6`) to get real generated stories.

Optional shortcut: `npm run seed` creates a demo guild with two players and
prints an invite code.

### 2. Mobile app

```bash
cd mobile
npm install
npx expo start
```

Scan the QR code with **Expo Go** (or press `i` / `a` for a simulator). The app
auto-discovers the backend at your laptop's LAN IP on port `4000`. To override,
set `EXPO_PUBLIC_API_URL` or edit `extra.apiUrl` in `mobile/app.json`.

> Both phones and your laptop must be on the same Wi-Fi.

---

## Two-phone demo script

1. **Both phones:** open the app, enter a name, add a few favorite
   books/games (these shape the story).
2. **Phone A:** Home → *Create a new story* → pick players = 2, a vibe (or leave
   it to Talegate) → *Create guild*. Note the 6-letter invite code.
3. **Phone B:** Home → *Enter invite code* → type the code → join. Phone A's
   lobby updates live.
4. **Phone A (host):** *Begin the story*. Claude writes the world, a character
   for each player, and Chapter 1. Both phones show the same chapter.
5. **Both phones:** read tonight's chapter, each pick a *different* choice and
   *lock it in*. Watch the "around the campfire" list update live on both
   devices as each friend decides.
6. **Either phone:** *Advance to tomorrow night*. Claude merges both choices into
   Chapter 2 — it appears on both phones, visibly reflecting what each of you
   chose.
7. Tap **Story so far** to show the branching timeline of chapters and choices.

---

## Roadmap (mentioned in the pitch, stubbed for later)

- Audio / ASMR narration (TTS) — `chapters.audio_url` and a play button are
  already wired as placeholders.
- Real Kindle integration + true scheduled 10pm nightly release (currently a
  deadline + manual advance).
- Persistent cross-story character arcs ("you've killed before…").
- Cloud backend (Supabase/Postgres) for play across networks.


## Detected evidence (automated analysis)

Indexed codebase: 116 recognized source files, 441 KB.
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- Next.js (technology) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- OpenAI (technology) — claimed on Devpost, not found in the code
- PostgreSQL (technology) — claimed on Devpost, not found in the code
- Swift (language) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 130)

```
.gitignore
cursorrules
design-system/_adherence.oxlintrc.json
design-system/_ds_bundle.js
design-system/_ds_manifest.json
design-system/.thumbnail
design-system/components/core/Avatar.d.ts
design-system/components/core/Avatar.jsx
design-system/components/core/Avatar.prompt.md
design-system/components/core/AvatarStack.d.ts
design-system/components/core/AvatarStack.jsx
design-system/components/core/AvatarStack.prompt.md
design-system/components/core/Badge.d.ts
design-system/components/core/Badge.jsx
design-system/components/core/Badge.prompt.md
design-system/components/core/Button.d.ts
design-system/components/core/Button.jsx
design-system/components/core/Button.prompt.md
design-system/components/core/core.card.html
design-system/components/core/IconButton.d.ts
design-system/components/core/IconButton.jsx
design-system/components/core/IconButton.prompt.md
design-system/components/core/Tag.d.ts
design-system/components/core/Tag.jsx
design-system/components/core/Tag.prompt.md
design-system/components/feedback/feedback.card.html
design-system/components/feedback/GuildDots.d.ts
design-system/components/feedback/GuildDots.jsx
design-system/components/feedback/GuildDots.prompt.md
design-system/components/feedback/ProgressTrack.d.ts
design-system/components/feedback/ProgressTrack.jsx
design-system/components/feedback/ProgressTrack.prompt.md
design-system/components/forms/Choice.d.ts
design-system/components/forms/Choice.jsx
design-system/components/forms/Choice.prompt.md
design-system/components/forms/forms.card.html
design-system/components/forms/Input.d.ts
design-system/components/forms/Input.jsx
design-system/components/forms/Input.prompt.md
design-system/components/forms/SegmentedControl.d.ts
design-system/components/forms/SegmentedControl.jsx
design-system/components/forms/SegmentedControl.prompt.md
design-system/components/forms/Stepper.d.ts
design-system/components/forms/Stepper.jsx
design-system/components/forms/Stepper.prompt.md
design-system/components/surfaces/Card.d.ts
design-system/components/surfaces/Card.jsx
design-system/components/surfaces/Card.prompt.md
design-system/components/surfaces/Sheet.d.ts
design-system/components/surfaces/Sheet.jsx
design-system/components/surfaces/Sheet.prompt.md
design-system/components/surfaces/StoryCover.d.ts
design-system/components/surfaces/StoryCover.jsx
design-system/components/surfaces/StoryCover.prompt.md
design-system/components/surfaces/surfaces.card.html
design-system/guidelines/brand-logo.card.html
design-system/guidelines/brand-voice.card.html
design-system/guidelines/colors-brand.card.html
design-system/guidelines/colors-gradients.card.html
design-system/guidelines/colors-guild.card.html
design-system/guidelines/colors-neutrals.card.html
design-system/guidelines/colors-night.card.html
design-system/guidelines/colors-status.card.html
design-system/guidelines/spacing-radii.card.html
design-system/guidelines/spacing-scale.card.html
design-system/guidelines/spacing-shadows.card.html
design-system/guidelines/type-display.card.html
design-system/guidelines/type-mono.card.html
design-system/guidelines/type-reading.card.html
design-system/guidelines/type-scale.card.html
design-system/README.md
design-system/SKILL.md
design-system/styles.css
design-system/tokens/base.css
design-system/tokens/colors.css
design-system/tokens/fonts.css
design-system/tokens/spacing.css
design-system/tokens/typography.css
design-system/ui_kits/babel-app/app.jsx
design-system/ui_kits/babel-app/frame.jsx
design-system/ui_kits/babel-app/home.jsx
design-system/ui_kits/babel-app/index.html
design-system/ui_kits/babel-app/reader.jsx
design-system/ui_kits/babel-app/README.md
design-system/ui_kits/babel-app/setup.jsx
design-system/ui_kits/babel-app/shelf.jsx
mobile/.gitignore
mobile/.npmrc
mobile/app.json
mobile/app/_layout.tsx
mobile/app/(tabs)/_layout.tsx
mobile/app/(tabs)/guild.tsx
mobile/app/(tabs)/shelf.tsx
mobile/app/(tabs)/tonight.tsx
mobile/app/design-check.tsx
mobile/app/guild/[id]/index.tsx
mobile/app/guild/[id]/timeline.tsx
mobile/app/guild/create.tsx
mobile/app/guild/join.tsx
mobile/app/home.tsx
mobile/app/index.tsx
mobile/app/settings.tsx
mobile/babel.config.js
mobile/LICENSE
mobile/metro.config.js
mobile/package.json
mobile/src/components/Casting.tsx
mobile/src/components/ds.tsx
mobile/src/components/Lobby.tsx
mobile/src/components/Reader.tsx
mobile/src/components/SetupParts.tsx
mobile/src/components/StoryNight.tsx
mobile/src/components/ui.tsx
mobile/src/lib/api.ts
mobile/src/lib/session.tsx
mobile/src/lib/socket.ts
mobile/src/lib/useCurrentGuild.ts
mobile/src/theme/theme.ts
mobile/svg.d.ts
mobile/tsconfig.json
[10 more files omitted for size]
```

### Dependencies

- mobile/package.json: @babel/core@^7.29.0, @expo-google-fonts/geist-mono@^0.4.2, @expo-google-fonts/newsreader@^0.4.1, @expo-google-fonts/schibsted-grotesk@^0.4.2, @react-native-async-storage/async-storage@2.2.0, @types/react@~19.1.10, babel-preset-expo@~54.0.10, expo@~54.0.35, expo-constants@~18.0.13, expo-font@~14.0.12, expo-linear-gradient@~15.0.8, expo-linking@~8.0.12, expo-router@~6.0.24, expo-status-bar@~3.0.9, lucide-react-native@^1.21.0, react@19.1.0, react-native@0.81.5, react-native-safe-area-context@~5.6.2, react-native-screens@~4.16.0, react-native-svg@15.12.1, react-native-svg-transformer@^1.5.3, socket.io-client@^4.8.3, typescript@~5.9.2
- server/package.json: cors@^2.8.5, dotenv@^16.4.7, express@^4.21.2, nanoid@^5.0.9, socket.io@^4.8.1

### Recent commits (newest first)

- Rebrand guild→friends, book-picker tale creation, settings, lobby nav
- Rebrand README from Babel to Talegate (talegate.club)
- Integrate Talegate design system and rebrand from Babel
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### design-system/SKILL.md

```markdown
---
name: talegate-design
description: Use this skill to generate well-branded interfaces and assets for Talegate — a shared bedtime-story app for long-distance friends (guilds of 2–4 read one month-long, choice-driven tale together). Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping or production.
user-invocable: true
---

Read the `README.md` file within this skill, and explore the other available files.

Talegate's mood is **dusk**: warm parchment by day, deep indigo "night" for reading; one twilight-indigo brand colour, one candle-amber accent; big soft macOS-Tahoe corners; bold Schibsted Grotesk display, literary Newsreader serif for story, Geist Mono for labels. Voice is an intimate bedtime narrator (serif, in-story) plus a warm, plain product voice (sans, talks to "you" and "your guild"). Sentence case; near-zero emoji beyond a 🌙 garnish.

Key files:
- `styles.css` — global entry; link it to inherit all tokens and fonts.
- `tokens/` — colors, typography, spacing/radii/shadows, fonts.
- `guidelines/*.card.html` — foundation specimens.
- `components/` — React primitives (Button, IconButton, Badge, Tag, Avatar, AvatarStack, Card, StoryCover, Sheet, Input, SegmentedControl, Stepper, Choice, ProgressTrack, GuildDots). Each has a `.prompt.md`.
- `ui_kits/babel-app/` — interactive recreation of the phone app.
- `assets/` — Talegate logo/mark.

If creating visual artifacts (slides, mocks, throwaway prototypes), copy assets out and create static HTML files for the user to view. If working on production code, copy assets and read the rules here to become an expert in designing with this brand.

If the user invokes this skill without any other guidance, ask them what they want to build or design, ask a few questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need.

```

### design-system/components/feedback/ProgressTrack.prompt.md

```markdown
A slim candle-amber progress bar for the month-long tale ("Night 12 of 30").

```jsx
<ProgressTrack value={12} total={30} label="Tale progress" />
<ProgressTrack value={12} total={30} onNight />
```

Pass `onNight` on the reader.

```

### server/package.json

```
{
  "name": "babel-server",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "Backend for Babel - shared story reading app (Gemini + Express + Socket.IO)",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js",
    "seed": "node src/seed.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "express": "^4.21.2",
    "nanoid": "^5.0.9",
    "socket.io": "^4.8.1"
  }
}

```

### mobile/package.json

```
{
  "name": "babel-mobile",
  "version": "1.0.0",
  "main": "expo-router/entry",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  },
  "dependencies": {
    "@expo-google-fonts/geist-mono": "^0.4.2",
    "@expo-google-fonts/newsreader": "^0.4.1",
    "@expo-google-fonts/schibsted-grotesk": "^0.4.2",
    "@react-native-async-storage/async-storage": "2.2.0",
    "expo": "~54.0.35",
    "expo-constants": "~18.0.13",
    "expo-font": "~14.0.12",
    "expo-linear-gradient": "~15.0.8",
    "expo-linking": "~8.0.12",
    "expo-router": "~6.0.24",
    "expo-status-bar": "~3.0.9",
    "lucide-react-native": "^1.21.0",
    "react": "19.1.0",
    "react-native": "0.81.5",
    "react-native-safe-area-context": "~5.6.2",
    "react-native-screens": "~4.16.0",
    "react-native-svg": "15.12.1",
    "socket.io-client": "^4.8.3"
  },
  "devDependencies": {
    "@babel/core": "^7.29.0",
    "@types/react": "~19.1.10",
    "babel-preset-expo": "~54.0.10",
    "react-native-svg-transformer": "^1.5.3",
    "typescript": "~5.9.2"
  },
  "private": true
}

```

### mobile/app/_layout.tsx

```typescript
import {
  GeistMono_400Regular,
  GeistMono_500Medium,
  GeistMono_600SemiBold,
} from "@expo-google-fonts/geist-mono";
import {
  Newsreader_400Regular,
  Newsreader_400Regular_Italic,
  Newsreader_500Medium,
  Newsreader_600SemiBold,
  Newsreader_600SemiBold_Italic,
} from "@expo-google-fonts/newsreader";
import {
  SchibstedGrotesk_400Regular,
  SchibstedGrotesk_500Medium,
  SchibstedGrotesk_600SemiBold,
  SchibstedGrotesk_700Bold,
  SchibstedGrotesk_800ExtraBold,
  useFonts,
} from "@expo-google-fonts/schibsted-grotesk";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { SessionProvider } from "../src/lib/session";
import { colors, fonts } from "../src/theme/theme";

export default function RootLayout() {
  const [fontsLoaded] = useFonts({
    SchibstedGrotesk_400Regular,
    SchibstedGrotesk_500Medium,
    SchibstedGrotesk_600SemiBold,
    SchibstedGrotesk_700Bold,
    SchibstedGrotesk_800ExtraBold,
    Newsreader_400Regular,
    Newsreader_400Regular_Italic,
    Newsreader_500Medium,
    Newsreader_600SemiBold,
    Newsreader_600SemiBold_Italic,
    GeistMono_400Regular,
    GeistMono_500Medium,
    GeistMono_600SemiBold,
  });

  if (!fontsLoaded) {
    // Keep the parchment canvas while the three families load.
    return <View style={{ flex: 1, backgroundColor: colors.bgApp }} />;
  }

  return (
    <SafeAreaProvider>
      <SessionProvider>
        <StatusBar style="dark" />
        <Stack
          screenOptions={{
            headerStyle: { backgroundColor: colors.bgApp },
            headerTintColor: colors.textStrong,
            headerTitleStyle: {
              fontFamily: fonts.sans.bold,
              color: colors.textStrong,
            },
            headerShadowVisible: false,
            contentStyle: { backgroundColor: colors.bgApp },
          }}
        >
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen name="index" options={{ headerShown: false }} />
          <Stack.Screen name="settings" options={{ headerShown: false, presentation: "modal" }} />
          <Stack.Screen name="home" options={{ headerShown: false }} />
          <Stack.Screen name="guild/create" options={{ headerShown: false }} />
          <Stack.Screen name="guild/join" options={{ headerShown: false }} />
          <Stack.Screen name="guild/[id]/index" options={{ title: "" }} />
          <Stack.Screen name="guild/[id]/timeline" options={{ title: "Story so far" }} />
          <Stack.Screen name="design-check" options={{ title: "Design check" }} />
        </Stack>
      </SessionProvider>
    </SafeAreaProvider>
  );
}

```

### mobile/app/index.tsx

```typescript
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
import Wordmark from "../assets/brand/talegate-wordmark.svg";
import { Body, Button, Caption, Field, GradientHero, Loading, Reading } from "../src/components/ui";
import { useSession } from "../src/lib/session";
import { colors, space } from "../src/theme/theme";

export default function Welcome() {
  const { user, hydrated, signIn } = useSession();
  const router = useRouter();
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (hydrated && user) router.replace("/(tabs)/tonight");
  }, [hydrated, user]);

  if (!hydrated || user) return <Loading label="Waking the library…" />;

  const onContinue = async () => {
    if (!name.trim()) return;
    setBusy(true);
    setError(null);
    try {
      await signIn(name.trim());
      router.replace("/(tabs)/tonight");
    } catch (e: any) {
      setError(e.message ?? "Could not connect to the server");
      setBusy(false);
    }
  };

  return (
    <KeyboardAvoidingView
      style={{ flex: 1, backgroundColor: colors.bgApp }}
      behavior={Platform.OS === "ios" ? "padding" : undefined}
    >
      <ScrollView
        contentContainerStyle={{ flexGrow: 1 }}
        keyboardShouldPersistTaps="handled"
        showsVerticalScrollIndicator={false}
      >
        <GradientHero kicker="a reading club for your friends" style={{ paddingBottom: space[10] }}>
          <View style={{ marginTop: space[4], marginBottom: space[5] }}>
            <Wordmark width={232} height={60} />
          </View>
          <Reading style={{ color: colors.textBody }}>
            A shared bedtime story for friends who live far apart. Gather your friends, read the same chapter
            each night, and shape the tale together.
          </Reading>
        </GradientHero>

        <View style={{ flex: 1, padding: space[6], justifyContent: "flex-end" }}>
          <Body dim style={{ marginBottom: space[5] }}>
            Each night you cross the gate into the story together.
          </Body>
          <Field
            label="What should your friends call you?"
            placeholder="e.g. Alex"
            value={name}
            onChangeText={setName}
            autoFocus
            onSubmitEditing={onContinue}
            returnKeyType="go"
          />
          {error ? (
            <Caption style={{ color: colors.danger, marginBottom: space[3] }}>{error}</Caption>
          ) : null}
          <Button title="Begin" onPress={onContinue} loading={busy} disabled={!name.trim()} size="lg" />
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

```

### server/src/index.js

```javascript
import "dotenv/config";
import express from "express";
import cors from "cors";
import http from "node:http";
import { Server as SocketServer } from "socket.io";
import { customAlphabet, nanoid } from "nanoid";

import { db } from "./db.js";
import { CHRONICLES, chronicleById, chronicleSummary } from "./chronicles.js";
import { generateNextChapter, usingMock, provider } from "./ai.js";

const PORT = process.env.PORT || 4000;
const inviteCode = customAlphabet("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", 6);

// A decision lands roughly every N nights after casting. Tunable: lower = more
// frequent decisions (livelier demo), higher = closer to the "every ~5 days"
// cadence. Decisions alternate individual → group → individual → …
const DECISION_EVERY = 3;

const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));

const server = http.createServer(app);
const io = new SocketServer(server, { cors: { origin: "*" } });

io.on("connection", (socket) => {
  socket.on("guild:join", (guildId) => {
    if (guildId) socket.join(`guild:${guildId}`);
  });
  socket.on("guild:leave", (guildId) => {
    if (guildId) socket.leave(`guild:${guildId}`);
  });
});

function emitGuild(guildId, event, payload) {
  io.to(`guild:${guildId}`).emit(event, payload);
}

// ---------------------------------------------------------------------------
// Decision schedule
// ---------------------------------------------------------------------------
/** What the chapter at this index asks of the players. */
function directiveForIndex(idx) {
  if (idx <= 0) return "casting";
  if (idx % DECISION_EVERY !== 0) return "none";
  const decisionNumber = idx / DECISION_EVERY; // 1, 2, 3, …
  return decisionNumber % 2 === 1 ? "individual" : "group";
}

// ---------------------------------------------------------------------------
// Serializers
// ---------------------------------------------------------------------------
function profileFor(userId) {
  return db.find("profiles", (p) => p.user_id === userId) ?? null;
}

function memberView(m) {
  const user = db.find("users", (u) => u.id === m.user_id);
  return {
    user_id: m.user_id,
    display_name: user?.display_name ?? "Player",
    character: m.character ?? null,
    profile: profileFor(m.user_id),
    joined_at: m.joined_at,
  };
}

/** Full cast = chronicle roster + who (if anyone) claimed each seat. */
function castFor(guild) {
  const roster = guild.story_bible?.characters ?? [];
  const casting = guild.current_chapter_index <= 0;
  return roster.map((c) => {
    const claimer = c.user_id ? db.find("users", (u) => u.id === c.user_id) : null;
    return {
      id: c.id,
      name: c.name,
      role: c.role,
      blurb: c.blurb ?? null,
      traits: c.traits ?? null,
      user_id: c.user_id ?? null,
      claimed_by_name: claimer?.display_name ?? null,
      // Unclaimed seats only become NPCs once casting is over.
      is_npc: !c.user_id && !casting,
    };
  });
}

function chapterView(chapter) {
  if (!chapter) return null;
  const choices = db.filter("choices", (c) => c.chapter_id === chapter.id);
  return { ...chapter, decision_type: chapter.decision_type ?? "none", choices };
}

function serializeGuild(guild) {
  const members = db
    .filter("guild_members", (m) => m.guild_id === guild.id)
    .map(memberView);
  const chapters = db
    .filter("chapters", (c) => c.guild_id === guild.id)
    .sort((a, b) => a.idx - b.idx);
  const current = chapters.find((c) => c.idx === guild.current_chapter_index);
  return {
    ...guild,
    members,
    cast: castFor(guild),
    chapters: chapters.map((c) => ({
      id: c.id,
      idx: c.idx,
      title: c.title,
      status: c.status,
      decision_type: c.decision_type ?? "none",
      released_at: c.released_at,
    })),
    current_chapter: chapterView(current),
  };
}

function findGuildOr404(req, res) {
  const guild = db.find("guilds", (g) => g.id === req.params.id);
  if (!guild) {
    res.status(404).json({ error: "Guild not found" });
    return null;
  }
  return guild;
}

// ---------------------------------------------------------------------------
// Health / meta
// ---------------------------------------------------------------------------
app.get("/api/health", (_req, res) => {
  res.json({ ok: true, ai: usingMock ? "mock" : "claude", provider });
});

app.get("/api/chronicles", (_req, res) => {
  res.json({ chronicles: CHRONICLES.map(chronicleSummary) });
});

// ---------------------------------------------------------------------------
// Users + profiles  (lightweight identity: a device gets a user id, no password)
// ---------------------------------------------------------------------------
app.post("/api/users", (req, res) => {
  const { display_name } = req.body ?? {};
  if (!display_name?.trim()) return res.status(400).json({ error: "display_name required" });
  const user = db.insert("users", {
    id: nanoid(),
    display_name: display_name.trim(),
    created_at: new Date().toISOString(),
  });
  res.json({ user });
});

app.get("/api/users/:id", (req, res) => {
  const user = db.find("users", (u) => u.id === req.params.id);
  if (!user) return res.status(404).json({ error: "User not found" });
  res.json({ user, profile: profileFor(user.id) });
});

app.put("/api/users/:id/profile", (req, res) => {
  const user = db.find("users", (u) => u.id === req.params.id);
  if (!user) return res.status(404).json({ error: "User not found" });
  const fields = ["favorite_books", "favorite_movies", "favorite_games", "hobbies", "interests"];
  const patch = { user_id: user.id };
  for (const f of fields) patch[f] = Array.isArray(req.body?.[f]) ? req.body[f] : [];

  const existing = profileFor(user.id);
  const profile = existing
    ? db.update("profiles", (p) => p.user_id === user.id, patch)
    : db.insert("profiles", patch);
  res.json({ profile });
});

// ---------------------------------------------------------------------------
// Guilds
// -------------------------------
[truncated — 14766 more characters]
```

### design-system/ui_kits/babel-app/app.jsx

```javascript
/* Talegate app — router shell. Mounts the interactive prototype. */
(function () {
  const { Phone, TabBar } = window;
  const { TonightScreen, GuildScreen } = window;
  const { ShelfScreen, DetailSheet } = window;
  const { SetupScreen, CharacterScreen } = window;
  const { ReaderScreen } = window;

  function App() {
    const [tab, setTab] = React.useState("tonight");
    const [overlay, setOverlay] = React.useState(null); // reader | setup | character
    const [detail, setDetail] = React.useState(null);

    const night = overlay === "reader";

    return (
      <Phone night={night}>
        {/* base tab screens */}
        {overlay === null && (
          <React.Fragment>
            {tab === "tonight" && <TonightScreen onRead={() => setOverlay("reader")} />}
            {tab === "shelf" && <ShelfScreen onPick={setDetail} />}
            {tab === "guild" && <GuildScreen />}
            <TabBar active={tab} onChange={(t) => { setTab(t); setDetail(null); }} />
            <DetailSheet story={detail} onClose={() => setDetail(null)} onStart={() => { setDetail(null); setOverlay("setup"); }} />
          </React.Fragment>
        )}

        {/* onboarding flow */}
        {overlay === "setup" && (
          <SetupScreen onBack={() => setOverlay(null)} onNext={() => setOverlay("character")} />
        )}
        {overlay === "character" && (
          <CharacterScreen onBack={() => setOverlay("setup")} onDone={() => { setTab("tonight"); setOverlay("reader"); }} />
        )}

        {/* reader */}
        {overlay === "reader" && (
          <ReaderScreen onExit={() => setOverlay(null)} />
        )}
      </Phone>
    );
  }

  ReactDOM.createRoot(document.getElementById("root")).render(<App />);
})();

```

### mobile/app/(tabs)/_layout.tsx

```typescript
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
import { Tabs } from "expo-router";
import { Library, Moon, Users } from "lucide-react-native";
import { Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { colors, fonts, radius, shadow, space } from "../../src/theme/theme";

const ICONS: Record<string, typeof Moon> = {
  tonight: Moon,
  shelf: Library,
  guild: Users,
};
const LABELS: Record<string, string> = {
  tonight: "Tonight",
  shelf: "Shelf",
  guild: "Friends",
};

/** Floating glass pill — Tonight · Shelf · Friends. */
function GlassTabBar({ state, navigation }: BottomTabBarProps) {
  const insets = useSafeAreaInsets();
  return (
    <View
      style={{
        position: "absolute",
        left: space[5],
        right: space[5],
        bottom: Math.max(insets.bottom, space[4]),
        height: 68,
        flexDirection: "row",
        alignItems: "center",
        paddingHorizontal: space[3],
        backgroundColor: "rgba(255,255,255,0.82)",
        borderRadius: radius.pill,
        borderWidth: 1,
        borderColor: "rgba(255,255,255,0.85)",
        ...shadow.lg,
      }}
    >
      {state.routes.map((route, index) => {
        const focused = state.index === index;
        const Icon = ICONS[route.name] ?? Moon;
        const color = focused ? colors.brand : colors.textMuted;
        const onPress = () => {
          const event = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true });
          if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
        };
        return (
          <Pressable
            key={route.key}
            onPress={onPress}
            style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 4, paddingVertical: space[3] }}
          >
            <Icon size={23} color={color} strokeWidth={focused ? 2.2 : 1.8} />
            <Text
              style={{
                fontSize: 11,
                color,
                fontFamily: focused ? fonts.sans.bold : fonts.sans.medium,
              }}
            >
              {LABELS[route.name] ?? route.name}
            </Text>
          </Pressable>
        );
      })}
    </View>
  );
}

export default function TabsLayout() {
  return (
    <Tabs
      tabBar={(props) => <GlassTabBar {...props} />}
      screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bgApp } }}
    >
      <Tabs.Screen name="tonight" />
      <Tabs.Screen name="shelf" />
      <Tabs.Screen name="guild" />
    </Tabs>
  );
}

```

### mobile/app/guild/[id]/index.tsx

```typescript
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import { Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Casting } from "../../../src/components/Casting";
import { IconButton } from "../../../src/components/ds";
import { Lobby } from "../../../src/components/Lobby";
import { Reader } from "../../../src/components/Reader";
import { Body, Caption, Loading } from "../../../src/components/ui";
import { api, Guild } from "../../../src/lib/api";
import { useSession } from "../../../src/lib/session";
import { subscribeToGuild } from "../../../src/lib/socket";
import { colors, space } from "../../../src/theme/theme";

export default function GuildScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { user } = useSession();
  const router = useRouter();
  const insets = useSafeAreaInsets();

  const goBack = () => {
    if (router.canGoBack()) router.back();
    else router.replace("/(tabs)/tonight");
  };
  const [guild, setGuild] = useState<Guild | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [starting, setStarting] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [advancing, setAdvancing] = useState(false);
  const [readerOpen, setReaderOpen] = useState(false);
  const reloading = useRef(false);

  const reload = useCallback(async () => {
    if (!id || reloading.current) return;
    reloading.current = true;
    try {
      const { guild } = await api.getGuild(id);
      setGuild(guild);
      await AsyncStorage.setItem("babel.lastGuild", JSON.stringify({ id: guild.id, name: guild.name }));
    } catch (e: any) {
      setError(e.message ?? "Could not load your tale");
    } finally {
      reloading.current = false;
    }
  }, [id]);

  useEffect(() => {
    reload();
    if (!id) return;
    const unsub = subscribeToGuild(id, {
      onGuildUpdate: () => reload(),
      onChapterNew: () => reload(),
      onChoiceUpdate: () => reload(),
    });
    return unsub;
  }, [id, reload]);

  const isCasting = guild?.status === "active" && guild.current_chapter?.decision_type === "casting";
  const isActiveStory = guild?.status === "active" && !isCasting;

  useEffect(() => {
    if (isActiveStory) setReaderOpen(true);
  }, [isActiveStory, guild?.current_chapter?.id]);

  if (error) {
    return (
      <View style={{ flex: 1, backgroundColor: colors.bgApp, padding: space[6], justifyContent: "center" }}>
        <Body style={{ color: colors.danger }}>{error}</Body>
        <Pressable onPress={reload} style={{ marginTop: space[5] }}>
          <Caption style={{ color: colors.brand }}>Tap to retry</Caption>
        </Pressable>
        <Pressable onPress={goBack} style={{ marginTop: space[4] }}>
          <Caption style={{ color: colors.textMuted }}>Go back</Caption>
        </Pressable>
      </View>
    );
  }
  if (!guild || !user) return <Loading label="Opening the story…" />;

  const start = async () => {
    setStarting(true);
    try {
      const { guild: g } = await api.startStory(guild.id);
      setGuild(g);
    } catch (e: any) {
      setError(e.message ?? "Could not start");
    } finally {
      setStarting(false);
    }
  };

  const submit = async (optionId: string) => {
    const chapter = guild?.current_chapter;
    if (!chapter || !user) return;
    setSubmitting(true);
    try {
      await api.submitChoice(chapter.id, { user_id: user.id, selected_option: optionId });
      await reload();
    } catch (e: any) {
      setError(e.message ?? "Could not submit");
    } finally {
      setSubmitting(false);
    }
  };

  const advance = async () => {
    setAdvancing(true);
    try {
      const { guild: g } = await api.advance(guild.id);
      setGuild(g);
      setReaderOpen(true);
    } catch (e: any) {
      setError(e.message ?? "Could not advance");
    } finally {
      setAdvancing(false);
    }
  };

  const claim = async (characterId: string) => {
    if (!user) return;
    try {
      const { guild: g } = await api.claimCharacter(guild.id, user.id, characterId);
      setGuild(g);
    } catch (e: any) {
      setError(e.message ?? "Could not claim that character");
    }
  };

  const title = guild.story_bible?.title ?? guild.name;

  return (
    <View style={{ flex: 1, backgroundColor: colors.bgApp }}>
      <Stack.Screen
        options={{
          title,
          headerShown: false,
        }}
      />

      {guild.status === "lobby" ? (
        <>
          <View style={{ paddingTop: insets.top + space[2], paddingHorizontal: space[5], paddingBottom: space[2] }}>
            <IconButton variant="plain" size="sm" onPress={goBack}>
              <ArrowLeft size={18} color={colors.textStrong} strokeWidth={1.9} />
            </IconButton>
          </View>
          <ScrollView contentContainerStyle={{ padding: space[5], paddingTop: space[3], paddingBottom: space[11] }} showsVerticalScrollIndicator={false}>
            <Lobby guild={guild} meId={user.id} onStart={start} starting={starting} />
          </ScrollView>
        </>
      ) : isCasting ? (
        <Casting guild={guild} meId={user.id} onClaim={claim} onAdvance={advance} advancing={advancing} onBack={goBack} />
      ) : null}

      {isActiveStory && guild.current_chapter ? (
        <Reader
          guild={guild}
          meId={user.id}
          visible={readerOpen}
          onClose={() => {
            setReaderOpen(false);
            router.replace("/(tabs)/tonight");
          }}
          onSubmit={submit}
          onAdvance={advance}
          submitting={submitting}
          advancing={advancing}
        />
      ) : null}
    </View>
  );
}

```

[104 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]