The Architecture

This document covers only the upgrade to the previous Choose Your Own Adventure Maker — the AI and social features added in this project — not the original application. It describes the database changes, the asynchronous job queue that powers AI generation, the social layer (ratings, comments, favourites, and views), and the supporting systems — admin settings, content guardrails, soft delete and maintenance, the theme engine, and the image gallery. Each section shows how these new pieces fit together and where they live in the code.

1. Social Features

1.1 Draft / Published Status (Shadow Draft System)

Two columns on the stories table: - status (ENUM('published','draft','deleted'), default draft) - published_story_id (INT DEFAULT NULL) — links a shadow draft back to its published original

Soft delete uses the 'deleted' status: live/visible queries filter status != 'deleted', while date_deleted records when it was trashed (used for trash ordering + retention purge). See §5.

Core Concept: Published stories are never set back to draft while being edited. Instead, a hidden shadow draft (a clone) is created for the author to work on. The published version stays live and playable. When the author publishes the draft, it replaces the original’s content while preserving the original storyID (so ratings, comments, views, and favourites are kept).

Two scenarios:

Scenario Behavior
New story (never published) Created as draft, stays draft until author clicks “Publish”. Visible only to owner/admins.
Editing a published story Published version stays live. A shadow draft is cloned (with published_story_id pointing to original). All edits happen on the draft. “Publish” replaces original content. “Discard” deletes the draft.

Rules: - Only published stories appear in the public gallery and can be rated/commented/favourited - Shadow drafts (published_story_id IS NOT NULL) are never shown in the gallery — they are working copies only - Standalone drafts (published_story_id IS NULL, status = 'draft') are shown to their owner in the gallery with a “Draft” badge - The author explicitly publishes via a “Publish” button in the editor - Only one shadow draft can exist per published story at a time

Edit flow for published stories: 1. Author clicks “Edit” on a published story 2. System checks: does a shadow draft already exist? (SELECT ... WHERE published_story_id = ?) - Yes → redirect to that existing draft - No → clone the story via create_edit_draft(), set status = 'draft' and published_story_id = originalStoryID 3. Author edits the draft freely — original remains published and playable 4. Author clicks “Publish” → draft content replaces the original (transaction), draft row is deleted 5. Or author clicks “Discard Draft” → draft is deleted, original is untouched

Image handling for shadow drafts:

Shadow drafts share the published story’s image folder to avoid duplicating files on every edit session:

Implementation touch-points: - get_all_stories() — filter: show published to all, standalone drafts to owner, hide shadow drafts from gallery - editor.php — route “Edit” on published stories through shadow draft creation/lookup - editor.php — Publish / Discard Draft / Unpublish buttons; banner “Editing a draft — published version remains live” - index.php — gallery filtering logic (hide shadow drafts) - get_story_image_dir() — helper for resolving image paths - publish_draft() — transaction: replace original content, move images, delete draft - discard_draft() — delete draft row + image folder

1.2 Ratings (1–5 Stars)

1.3 Favourites

1.4 Comments

1.5 View Tracking

1.6 Story Summary Page (summary.php)

Sits between the gallery and play.php: - Shows story cover image, title, description, author, theme, genre - Displays stats: view count, average rating, favourite count, comment count - Play button + Edit button (for owner/admin) + Clone button (logged-in users) - Comments section at the bottom - Visiting this page (for published stories) triggers a view count increment - Owners and admins can view their draft stories here as well (no auto-redirect to editor)


2. AI Job Queue Architecture

2.1 Overview

The AI system uses an asynchronous job queue because shared hosting cannot run long-lived processes. The flow is:

  1. User request → PHP creates a cyoa_ai_jobs row (status: pending) and immediately returns — the browser does not wait on the page
  2. Dispatcher (cron/ai_dispatcher.php) → Invoked by cron (typically once a minute on shared hosting). Each invocation runs several dispatch passes spaced a few seconds apart ($DISPATCH_PASSES × $DISPATCH_INTERVAL), so jobs born mid-minute are still picked up quickly. Each pass times out stale jobs, claims pending jobs (with an image-concurrency cap), and spawns a worker per claimed job, then the invocation exits.
  3. Worker (cron/ai_worker.php) → Routes the job to the appropriate handler; the handler calls the AI API, applies the result to the database, and marks the job completed
  4. Header polling → JavaScript in header.php polls api_jobs.php?action=unseen_count every 5 seconds. When completed/failed jobs exist, the badge on the Job Queue link updates
  5. User reviews → User opens job_queue.php to see all jobs; seen_at is stamped on load to clear the badge

2.2 Job Types

job_type AI Service Input Output
image OpenAI (model configurable, default gpt-image-2) Prompt text, image style + optional mood modifier Image downloaded and saved to images/stories/{storyID}/; filename + prompt-used stored in result_json
scene Claude API Story context, genre, scene direction, tone, type JSON: {title, description, hint, choices[]} applied directly to the scene
story Claude API (multi-phase) Story ID (pre-created draft), premise, genre, tone, target scenes, endings count, audience, image settings, publish flag Plan phase → scene-write phase → child image jobs queued for cover + each scene

Child jobs (image jobs spawned by a story job) have parent_job_id set to the parent story job’s ID. Top-level jobs have parent_job_id IS NULL — this distinction matters for the per-user pending-job limit (counts top-level only) and for chain-cost rollup to the parent.

Note on “theme”: a story’s theme is strictly a visual label (a set of colours + fonts; see §6 Theme Engine). It is not sent to text or image generation — neither the story/scene prompts nor the image prompts receive the theme. The semantic hint for generation is the genre.

2.3 Job Statuses

Status Meaning
pending Queued, waiting for cron pickup
running Cron worker is actively processing
completed Result applied; result_json populated
completed_with_errors Partially completed (e.g. some scenes written but not all images generated)
failed Error stored in error_message; user can retry
cancelled User cancelled a pending job. Only pending jobs can be cancelled — once running, the API call is already in flight and cannot be aborted.

2.4 Dispatcher / Worker Design (Parallel Processing)

The cron system uses a dispatcher + worker pattern so multiple jobs run in parallel.

Dispatcher (cron/ai_dispatcher.php) — invoked by cron (commonly once a minute). Two tuning constants at the top of the file control the within-invocation cadence:

Keep (DISPATCH_PASSES − 1) × DISPATCH_INTERVAL comfortably under the cron period so a run never bleeds into the next invocation. Per invocation:

1. Connect to database; bail early if app_setting('ai_enabled') is off
2. Launch maintenance.php once per calendar day (guarded by today's maintenance log)
3. For each pass (1..DISPATCH_PASSES):
   a. sleep($DISPATCH_INTERVAL) on passes after the first
   b. Timeout stale jobs (running > ai_job_timeout_seconds → mark failed)
   c. Compute free image slots = ai_max_concurrent_image_jobs − running image jobs
   d. Claim pending NON-image jobs atomically (status pending → running), then claim up to
      "free image slots" pending image jobs
   e. For each claimed job: spawn `php ai_worker.php <job_id>` as a detached background process
4. Exit (workers keep running independently)

Why multiple passes: a job created while an invocation is alive is claimed by the next pass rather than waiting for the next cron minute. Workers are launched detached (proc_open on Windows, exec("cmd &") on Linux), so a pass never waits on them.

Worker (cron/ai_worker.php) — one process per job:

1. Receive job_id as CLI argument
2. Fetch job row, verify status = 'running'
3. Route based on job_type:
   - image  → cron/ai_image_handler.php
   - scene  → cron/ai_scene_handler.php
   - story  → cron/ai_story_handler.php
4. Handler calls the AI API, applies result to DB, marks job completed/failed

Concurrency: All claimed jobs run simultaneously in separate PHP processes. Each worker has its own memory space and execution timeout. The ai_max_concurrent_image_jobs setting limits simultaneous image jobs to prevent OpenAI rate-limit errors.

Timeout: Controlled by app_setting('ai_job_timeout_seconds') (default 600 s). The dispatcher marks running jobs that exceed this threshold as failed.

Platform support: Dispatcher spawns workers with exec("cmd &") on Linux and proc_open() (with NUL stdin + per-job log file) on Windows (XAMPP), so the worker inherits the full environment. Each worker writes to cron/logs/job_<id>.log; the dispatcher itself logs to cron/logs/dispatcher.log.

2.5 Result Application

Results are applied server-side by the handler, before marking the job completed. No browser interaction is required.

AI apply operations always target the story in draft status, and any AI-applied change sets the story back to draft. If the apply step itself fails (DB constraint, etc.), the job is marked failed with the error, and AI-generated content is preserved in result_json for manual retry.

2.6 AI API Configuration

All runtime AI settings are stored in the cyoa_ai_settings database table and accessed via app_setting('key'). config.php retains only values needed before DB connect.

config.php retains: - DB credentials (DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PREFIX) - SMTP / email settings - APP_URL, MAIN_ADMIN, APP_TIMEZONE - AI_IMAGE_PRICING array — per-image cost lookup for all four models (not admin-editable) - AI_COST_INPUT_PER_M, AI_COST_OUTPUT_PER_M — Claude token rates (not admin-editable)

cyoa_ai_settings table (admin-editable via the Site / Content / Users settings pages). Defaults live in SETTING_DEFAULTS in settings.php (the app works pre-migration off these); the two API keys are intentionally absent from defaults (null until configured).

Setting key Type Default Purpose
anthropic_api_key string (sensitive) — (null) Claude API key (site-wide)
openai_api_key string (sensitive) — (null) OpenAI API key (site-wide)
anthropic_model select claude-sonnet-4-6 Claude model for all text generation
openai_image_model select gpt-image-2 OpenAI image model
openai_image_quality select medium Default image quality (low/medium/high)
openai_image_format select jpeg Output format / file extension
scene_thumb_size int 200 Scene thumbnail size (px)
ai_enabled bool 1 Global AI processing on/off switch
ai_job_timeout_seconds int 600 Seconds before a stale running job is failed (watchdog)
ai_image_request_timeout int 300 Per-image OpenAI curl timeout
ai_claude_request_timeout int 180 Per-call Claude curl timeout
ai_max_pending_per_user int 5 Max top-level pending jobs per user
ai_max_concurrent_image_jobs int 2 Max simultaneous image jobs (rate-limit guard)
app_title string Choose Your Own Adventure! Site title in header / page titles
story_genres JSON array 20 genres incl. Other Genre chips + filter options
image_styles JSON object category → styles map Grouped image-style dropdown source
image_moods JSON array 6 moods Image mood-modifier options
guardrails_enabled bool 1 Append content guardrails to prompts
guardrails_text text 6 topics Restricted topics, one per line
trash_retention select 1week Soft-delete purge window (1day/1week/1month)
log_retention select 1month Log-file purge window
gallery_page_size int 12 Home gallery stories per page
jobs_history_page_size int 25 Job History rows per page
gallery_tile_size int 220 Story image-gallery tile px (presets in config.php)
gallery_filmstrip_size int 72 Gallery lightbox filmstrip thumb px
gallery_tile_spacing int 16 Gallery tile gap px

Access pattern: app_setting('key') — returns the DB value, else the SETTING_DEFAULTS default, else null. Per-user BYOK keys + quality override live on cyoa_ai_users (see §2.7).

2.7 Bring Your Own Keys (BYOK)

Users can supply their own API keys so their AI usage is billed to their own account rather than the site owner’s.

Key resolution order (in cron handlers): 1. If the job’s user has a non-empty claude_api_key or openai_api_key stored in the cyoa_ai_users table, use that key for the matching API call. 2. Otherwise fall back to app_setting('anthropic_api_key') / app_setting('openai_api_key').

Per-user settings stored on cyoa_ai_users: - claude_api_key VARCHAR(255) — personal Claude API key (optional) - openai_api_key VARCHAR(255) — personal OpenAI API key (optional) - openai_image_quality VARCHAR(10) — personal image quality override (optional)

Account page (account.php): “API Keys” section lets users view/update/clear their stored keys and quality override.

2.8 Prompt Templates

All AI system prompt text lives in standalone .txt files under prompts/. Prompts that contain dynamic values use {PLACEHOLDER} tokens substituted in PHP via str_replace().

prompts/
  image_system.txt              — System context for OpenAI scene image generation
  cover_image_system.txt        — System context for the story cover image
  scene_system.txt              — System prompt for single scene generation (Claude)
  story_plan_system.txt         — System prompt for story planning phase (Claude)
  story_scene_writer_system.txt — System prompt for scene writing phase (Claude)

Helper function load_prompt(string $name, array $vars = []): string reads the file and performs placeholder substitution. See ai-prompts.md (same folder) for the full prompt-construction logic.

2.9 Prompt Assembly

When the AI writes a full story, the prompt is not a single block of text — it is assembled from several sources and sent in stages. The diagram below traces that pipeline: the user’s choices and seed data are resolved and merged into the prompt templates, then the request fans out into a plan call, one scene call per scene, and one image call per image. See ai-prompts.md (same folder) for the exact construction logic.

Flowchart of the full-story prompt assembly pipeline: data sources merge into templates, then fan out into plan, scene, and image AI calls
Full-story prompt assembly: the data sources merge into the templates, then fan out into the plan call, one scene call per scene, and one image call per image.

2.10 Job Cost Tracking

The cyoa_ai_jobs table has cost columns:

Column Type Purpose
input_tokens INT Claude input tokens consumed
output_tokens INT Claude output tokens consumed
image_count INT Number of images generated
cost_usd DECIMAL(10,6) Calculated cost for this job

Cost reference rates live in config.php (not the DB), so they can be tuned in one place:

Model Low Medium High
gpt-image-1-mini $0.005 $0.011 $0.036
gpt-image-1 $0.011 $0.042 $0.167
gpt-image-1.5 $0.009 $0.034 $0.133
gpt-image-2 $0.006 $0.053 $0.211

For story parent jobs, the displayed cost is the chain cost — the sum of cost_usd across the parent job and all child jobs with the same parent_job_id. Child jobs show no cost label. A suffix indicates the chain is still running.

Functions: db_update_job_cost(), db_get_chain_cost().

2.11 File Structure for AI Features

cron/
  ai_dispatcher.php      — Cron entry point; multi-pass claim, spawns workers, daily maintenance
  ai_worker.php          — Single-job router; receives job_id via CLI arg
  ai_story_handler.php   — Multi-phase Claude handler for `story` jobs (+ premise-seed resolution)
  ai_scene_handler.php   — Claude handler for single `scene` jobs
  ai_image_handler.php   — OpenAI image handler for `image` jobs
  ai_apply.php           — Apply completed results to the DB; auto-publish gate
  ai_helpers.php         — Shared cron helpers (HTTP, logging, prompt loading)
  maintenance.php        — Daily cleanup: purge expired trash + old logs
  logs/                  — Per-job + dispatcher + maintenance + guardrail logs

cli/
  create_stories.php     — Batch story-creation jobs (--file or generated)
  export_story.php       — Bundle a story (rows + scenes + choices + images) for transfer
  import_story.php       — Re-create an exported bundle on this instance with fresh IDs
  validate_play_fonts.php— Verify every play-font family/weight resolves on Google Fonts
  sample_stories.json    — Example batch input for create_stories.php --file

data/                    — Data-driven content (loaded via data.php):
  premises.json          — Story premise seeds (genre-tagged) for blank-premise creation
  audiences.json         — Audience keys + writing guidance
  themes.json            — Theme presets (colours + fonts)
  play_fonts.json        — Curated, mood-tagged Google-Fonts allow-list

prompts/
  image_system.txt  cover_image_system.txt
  scene_system.txt  story_plan_system.txt  story_scene_writer_system.txt

2.12 Security Considerations


3. Job Queue Page (job_queue.php)

A dedicated page accessible from the header navigation for all logged-in users. Results are already applied by the time the user visits — this page is for visibility and navigation only.

User view

Admin view

When an admin visits job_queue.php, they see all users’ jobs with an additional Username column. All actions (retry, cancel) are available across all users.

“Seen” tracking and badge dismissal

When the page loads, all the current user’s completed and failed jobs with seen_at IS NULL are marked seen_at = NOW() in a single UPDATE. This clears the badge.


4. Header Notification Badge

All notification and polling logic lives in header.php. No page-specific JS is needed.

Badge behaviour

Polling

header.php includes a JS block that polls api_jobs.php?action=unseen_count every 5 seconds:

(function () {
    const badge = document.getElementById('job-queue-badge');
    if (!badge) return; // not logged in

    function checkJobs() {
        fetch('api_jobs.php?action=unseen_count')
            .then(r => r.json())
            .then(data => {
                if (data.count > 0) {
                    badge.textContent = data.count;
                    badge.hidden = false;
                } else {
                    badge.hidden = true;
                }
            })
            .catch(() => {});
    }

    checkJobs();
    setInterval(checkJobs, 5000);
})();

5. Soft Delete, Trash & Maintenance

Stories are soft-deleted, not removed immediately:


6. Theme Engine (visual layer) — theme.php, fonts.php

A story’s theme is purely visual: a body font + heading font (from the curated allow-list) and bg/text/accent colours, stored as JSON in stories.theme_json. The legacy stories.theme column holds a preset slug and serves as the per-field fallback. Theme is never sent to AI generation.


7. Story Image Gallery — gallery.php

Per-story image gallery (cover first, then every scene that has an image; image-less scenes skipped).


8. Data-Driven Content Layer — data.php + data/*.json

data.php provides load_data_json('name') (cached read of data/name.json). Content that used to be hardcoded now lives in JSON so it can grow without code edits:

“Any” defaults: the create form’s Genre / Tone / Audience default to Any; ai_resolve_story_params() expands these to concrete random values in code before the job is queued, so the AI always receives a concrete value while the UI stays unconstrained. A blank image style resolves to one random style for the whole story (ai_random_image_style()) so every image shares a look.


9. Cross-Instance Story Transfer — cli/export_story.php / cli/import_story.php

Moves a story between app instances (e.g. local ↔︎ host), which differ in IDs and image folders.