Skip to main content
Learning Paths are CodeJam’s interactive tutorial system. Each guide is AI-generated on-demand using OpenRouter, covering coding concepts across JavaScript, Python, C++, HTML, and CSS.
Guides are generated once and cached. Users receive the same high-quality content across sessions.

Guides Overview

AI-Generated

Powered by OpenRouter and Gemini 2.0 Flash for real-time content generation.

Progress Tracking

Track completion percentage, last accessed time, and unlock achievements.

Campaign Integration

Guides power “briefing” nodes in the campaign system.

XP Rewards

Completing guides awards 150 XP and unlocks campaign progression.

Guides Schema

// From convex/schema.ts:34-40
guides: defineTable({
  slug: v.string(),
  title: v.string(),
  category: v.string(),
  content: v.string(), // Markdown
  duration: v.string(),
}).index("by_slug", ["slug"])

Guide Fields

  • slug - Unique identifier (e.g., "html-basics", "python-loops")
  • title - Display name (e.g., "HTML: The Skeleton")
  • category - Language/topic (e.g., "HTML", "Python")
  • content - Full markdown tutorial content
  • duration - Estimated reading time (e.g., "5 min read")

Progress Tracking Schema

// From convex/schema.ts:48-54
guide_progress: defineTable({
  userId: v.id("users"),
  guideId: v.id("guides"),
  progress: v.number(), // 0-100
  lastAccessed: v.number(),
  completed: v.boolean(),
})
.index("by_user", ["userId"])
.index("by_user_guide", ["userId", "guideId"])
.index("by_user_lastAccessed", ["userId", "lastAccessed"])

Progress Fields

  • progress - Percentage completion (0-100)
  • lastAccessed - Timestamp of last view
  • completed - Boolean flag for guide completion
The by_user_lastAccessed index enables fast “recently accessed” queries for dashboard widgets.

AI Generation System

Guides are generated on-demand using OpenRouter:
// From convex/guides.ts:134-251
export const getOrGenerateGuide = action({
  args: {
    slug: v.string(),
    title: v.string(),
    category: v.string(),
  },
  handler: async (ctx, args) => {
    // 1. Check DB first
    const existing = await ctx.runQuery(
      internal.internalGuides.getGuideInternal, 
      { slug: args.slug }
    );
    if (existing) return existing;

    // 2. Create placeholder immediately
    const guideId = await ctx.runMutation(
      internal.internalGuides.saveGuide, 
      {
        slug: args.slug,
        title: args.title,
        category: args.category,
        content: "",
        duration: "Calculating...",
      }
    );

    const apiKey = process.env.OPENROUTER_API_KEY || process.env.GEMINI_API_KEY;
    if (!apiKey) {
      throw new Error("No AI API Key configured");
    }

    const client = new OpenRouter({
      apiKey: apiKey,
      serverURL: process.env.OPENROUTER_API_URL,
    });

    const prompt = `
      Write a comprehensive, engaging coding tutorial in Markdown format.
      Topic: "${args.title}"
      Language/Category: "${args.category}"
      Target Audience: Developer bootcamp students.

      Structure Requirements:
      1. H1 Title
      2. Introduction (Why this matters)
      3. Core Concepts (H2)
      4. Code Examples (Use markdown code blocks with language tags)
      5. "Pro Tip" blockquote
      6. Conclusion

      Tone: High-energy, professional, clear.
      Length: ~500 words.
    `;

    const stream = await client.chat.send({
      model: "google/gemini-2.0-flash-001",
      messages: [
        { role: "system", content: "You are an expert technical writer." },
        { role: "user", content: prompt }
      ],
      stream: true,
    });

    let fullContent = "";
    let chunkCount = 0;
    let lastUpdate = Date.now();

    // Stream tokens and update DB periodically
    for await (const chunk of stream) {
      const text = chunk.choices[0]?.delta?.content || "";
      fullContent += text;
      chunkCount++;

      // Update every 20 chunks OR every 1 second
      const now = Date.now();
      if (chunkCount % 20 === 0 || (now - lastUpdate > 1000)) {
          try {
              await ctx.runMutation(internal.internalGuides.updateGuide, {
                  id: guideId,
                  content: fullContent
              });
              lastUpdate = now;
          } catch (err) {
              console.error("Stream update failed:", err);
          }
      }
    }

    const duration = Math.ceil(fullContent.split(' ').length / 200) + " min read";

    // Final update
    await ctx.runMutation(internal.internalGuides.updateGuide, {
      id: guideId,
      content: fullContent,
      duration: duration
    });

    return {
      _id: guideId,
      slug: args.slug,
      title: args.title,
      category: args.category,
      content: fullContent,
      duration: duration
    };
  },
});

Generation Flow

1

Check Cache

Query database for existing guide by slug. Return immediately if found.
2

Create Placeholder

Insert guide with empty content and "Calculating..." duration. Prevents duplicate generation requests.
3

Call OpenRouter

Send structured prompt to Gemini 2.0 Flash via OpenRouter SDK with streaming enabled.
4

Stream Updates

As tokens arrive, update the database every 20 chunks or 1 second. Enables real-time progress in UI.
5

Calculate Duration

Estimate reading time based on word count (200 words/min).
6

Final Commit

Update guide with complete content and calculated duration.
Generation requires the OPENROUTER_API_KEY or GEMINI_API_KEY environment variable. Missing API keys will throw an error.

Starting a Guide

When a user begins a guide:
// From convex/guides.ts:34-59
export const startGuide = mutation({
  args: { guideId: v.id("guides") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthorized");

    const existing = await ctx.db
      .query("guide_progress")
      .withIndex("by_user_guide", (q) => 
        q.eq("userId", userId).eq("guideId", args.guideId)
      )
      .first();

    if (!existing) {
      await ctx.db.insert("guide_progress", {
        userId,
        guideId: args.guideId,
        progress: 0,
        lastAccessed: Date.now(),
        completed: false,
      });
    } else {
      await ctx.db.patch(existing._id, {
        lastAccessed: Date.now(),
      });
    }
  },
});
Starting a guide creates a progress record with progress: 0. Subsequent visits only update lastAccessed.

Completing a Guide

// From convex/guides.ts:61-107
export const completeGuide = mutation({
  args: { guideId: v.id("guides") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthorized");

    const existing = await ctx.db
      .query("guide_progress")
      .withIndex("by_user_guide", (q) => 
        q.eq("userId", userId).eq("guideId", args.guideId)
      )
      .first();

    if (existing && !existing.completed) {
      await ctx.db.patch(existing._id, {
        completed: true,
        progress: 100,
        lastAccessed: Date.now(),
      });

      // Award XP
      await ctx.runMutation(internal.activity.logActivityInternal, {
        userId,
        type: "Guide Completion",
        xp: 150, // Big reward for finishing a guide
      });

      // UPDATE CAMPAIGN PROGRESS
      const guide = await ctx.db.get(args.guideId);
      if (guide) {
        const allNodes = await ctx.db.query("campaign_nodes").collect();
        const node = allNodes.find(n => n.data?.guideId === guide.slug);

        if (node) {
          await ctx.runMutation(internal.campaign.internalCompleteNode, {
            userId,
            nodeSlug: node.slug
          });
        }
      }
    }
  },
});

Completion Rewards

Guide Completion Bonuses

XP Reward: 150 XP
Campaign Progress: Unlocks next campaign nodes
Activity Log: Recorded in activity_logs for friends feed
Badge Eligibility: May unlock achievement badges

Fetching Active Guide

Dashboard displays the user’s most recently accessed guide:
// From convex/guides.ts:9-32
export const getActiveGuide = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return null;

    const progress = await ctx.db
      .query("guide_progress")
      .withIndex("by_user_lastAccessed", (q) => q.eq("userId", userId))
      .order("desc")
      .first();

    if (!progress) return null;

    const guide = await ctx.db.get(progress.guideId);
    if (!guide) return null;

    return {
      ...guide,
      progress: progress.progress,
      completed: progress.completed,
    };
  },
});

Campaign Integration

Guides power briefing nodes in the campaign:
// From convex/campaign.ts:164-176 (example briefing node)
{
  slug: "node-1-html-basics",
  title: "HTML: The Skeleton",
  type: "briefing",
  tier: "html",
  position: { x: 100, y: 300 },
  data: { 
    description: "Learn the structure of the web.",
    guideId: "html-basics" // Links to guides table
  }
}
When a guide is completed, the corresponding campaign node is automatically marked complete:
// From convex/guides.ts:88-103
const guide = await ctx.db.get(args.guideId);
if (guide) {
  const allNodes = await ctx.db.query("campaign_nodes").collect();
  const node = allNodes.find(n => n.data?.guideId === guide.slug);

  if (node) {
    await ctx.runMutation(internal.campaign.internalCompleteNode, {
      userId,
      nodeSlug: node.slug
    });
  }
}
This bidirectional link ensures campaign progression stays in sync with guide completion.

Available Learning Tracks

Guides are categorized by language/topic:
  • HTML Basics
  • Semantic Elements
  • Forms & Input
  • Accessibility
  • CSS Intro
  • Flexbox Layouts
  • Grid Systems
  • Animations
  • JS Logic
  • Functions & Closures
  • Async Programming
  • DOM Manipulation
  • Python Basics
  • Data Structures
  • Object-Oriented Python
  • Error Handling
  • C++ Basics
  • Memory Management
  • STL Containers
  • Algorithm Optimization
  • Tailwind Basics
  • Utility Classes
  • Custom Themes
  • Component Patterns

Markdown Content Structure

AI-generated guides follow this structure:
# [Title]

## Introduction
Why this topic matters and what you'll learn.

## Core Concepts
Detailed explanation of key concepts.

## Code Examples
```javascript
// Practical code examples with explanations
const example = "formatted with syntax highlighting";
Pro Tip: Expert advice and best practices.

Conclusion

Summary and next steps.

<Tip>
  Content is rendered in MDX format, allowing for interactive components and code sandboxes in the future.
</Tip>

---

## Streaming Progress UI

Since guides update incrementally during generation, clients can display real-time progress:

```typescript
const [guide, setGuide] = useState(null);
const [isGenerating, setIsGenerating] = useState(true);

useEffect(() => {
  const subscription = convex.query(
    api.guides.getGuide, 
    { slug: "html-basics" }
  ).subscribe((data) => {
    setGuide(data);
    if (data?.duration !== "Calculating...") {
      setIsGenerating(false);
    }
  });
  
  return () => subscription.unsubscribe();
}, []);

return isGenerating ? (
  <div>Generating guide... {guide?.content.length || 0} characters</div>
) : (
  <MarkdownRenderer content={guide.content} />
);

Configuration

Learning Paths require the OPENROUTER_API_KEY environment variable.
OPENROUTER_API_KEY=your_openrouter_key_here
Optional variables:
GEMINI_API_KEY=fallback_key  # Used if OPENROUTER_API_KEY is missing
OPENROUTER_API_URL=custom_endpoint  # Override default OpenRouter URL
model: "google/gemini-2.0-flash-001"
Why Gemini 2.0 Flash:
  • Fast streaming (low latency)
  • High-quality technical writing
  • Cost-effective for educational content
  • Strong markdown formatting

Performance Optimizations

Guides are generated once and cached indefinitely. Subsequent requests return the cached version instantly.
Empty guides are inserted immediately before generation starts, preventing duplicate generation if multiple users request the same guide.
Database updates are throttled to every 20 chunks or 1 second during streaming, reducing database load.
The by_user_lastAccessed index enables O(1) lookups for “continue learning” widgets on dashboards.

Error Handling

try {
  const stream = await client.chat.send({...});
  // ... streaming logic
} catch (e) {
  console.error("Guide Gen Error", e);
  return null;
}
If generation fails:
  • Guide remains as empty placeholder with duration: "Calculating..."
  • User can retry by refreshing
  • Admin can manually populate content
Monitor OpenRouter quota limits. Generation failures often indicate rate limiting or exhausted credits.

Campaign System

See how guides integrate with campaign nodes

Boss Battles

Apply learned concepts in boss scenarios

Build docs developers (and LLMs) love