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
Check Cache
Query database for existing guide by slug. Return immediately if found.
Create Placeholder
Insert guide with empty content and "Calculating..." duration. Prevents duplicate generation requests.
Call OpenRouter
Send structured prompt to Gemini 2.0 Flash via OpenRouter SDK with streaming enabled.
Stream Updates
As tokens arrive, update the database every 20 chunks or 1 second. Enables real-time progress in UI.
Calculate Duration
Estimate reading time based on word count (200 words/min).
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
Recommended Model
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
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