Skip to main content
Ghost Mode is CodeJam’s asynchronous multiplayer system. Challenge friends to coding duels without coordinating schedules - their best scores become your opponent.
Ghost Mode enables revenge loops, competitive streaks, and trash talk without the need for real-time availability.

How Ghost Mode Works

Instead of waiting for friends to accept live battles, Ghost Mode instantly creates a match using your opponent’s best recorded score from their previous games.
1

Challenge a Friend

Select a friend and game mode. Their best score is locked in as your target.
2

Battle the Ghost

Play the game mode solo. You’re racing against their best performance, represented as a “ghost.”
3

Results & Revenge

If you beat their score, they receive a revenge notification with the XP difference.
4

Revenge Loop

Your friend can challenge you back to reclaim their title. The cycle continues.

Battle Schema

Ghost battles are stored in the battles table with mode: "ghost":
// From convex/schema.ts:76-86
battles: defineTable({
  challengerId: v.id("users"),
  opponentId: v.id("users"),
  gameId: v.string(),
  mode: v.union(v.literal("live"), v.literal("ghost")),
  status: v.union(
    v.literal("pending"), 
    v.literal("active"), 
    v.literal("completed"), 
    v.literal("rejected")
  ),
  challengerScore: v.optional(v.number()),
  opponentScore: v.optional(v.number()),
  winnerId: v.optional(v.id("users")),
  createdAt: v.number(),
})
Ghost battles start with status: "active" immediately, unlike live battles which start as "pending" until accepted.

Creating a Ghost Battle

When you challenge a friend to a ghost battle:
// From convex/social.ts:215-252
export const createBattle = mutation({
  args: { 
    opponentId: v.id("users"), 
    gameId: v.string(),
    mode: v.union(v.literal("live"), v.literal("ghost"))
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthorized");

    const allowed = await checkNotAnonymous(ctx, userId);
    if (!allowed) {
      return { error: "Guest users cannot access social features." };
    }

    let opponentScore = 0;

    // If Ghost Mode, lock in the opponent's best score immediately
    if (args.mode === "ghost") {
        const stats = await ctx.db
            .query("game_stats")
            .withIndex("by_user_game", (q) => 
              q.eq("userId", args.opponentId).eq("gameId", args.gameId)
            )
            .first();
        
        // Baseline score if opponent has never played
        opponentScore = stats?.bestScore || 50; 
    }

    const battleId = await ctx.db.insert("battles", {
        challengerId: userId,
        opponentId: args.opponentId,
        gameId: args.gameId,
        mode: args.mode,
        status: args.mode === "ghost" ? "active" : "pending",
        opponentScore: args.mode === "ghost" ? opponentScore : undefined,
        createdAt: Date.now(),
    });

    return { success: true, battleId };
  },
});
If your friend has never played the selected game mode, their ghost scores a baseline of 50 points, making it an easy win for you.

Fetching Ghost Scores

Before creating a battle, you can preview an opponent’s best score:
// From convex/social.ts:203-213
export const getGhostScore = query({
  args: { userId: v.id("users"), gameId: v.string() },
  handler: async (ctx, args) => {
    const stats = await ctx.db
      .query("game_stats")
      .withIndex("by_user_game", (q) => 
        q.eq("userId", args.userId).eq("gameId", args.gameId)
      )
      .first();
    
    return stats ? stats.bestScore : 0;
  },
});
This allows the UI to display:

Challenge Preview

Opponent: Sarah Chen
Game Mode: Function Fury
Best Score: 1,240 XP
Your Best: 980 XP
Can you beat her score?

Finishing a Ghost Battle

Once you complete the game, your score is compared against the ghost:
// From convex/social.ts:283-329
export const finishBattle = mutation({
    args: { battleId: v.id("battles"), score: v.number() },
    handler: async (ctx, args) => {
        const userId = await getAuthUserId(ctx);
        if (!userId) throw new Error("Unauthorized");

        const battle = await ctx.db.get(args.battleId);
        if (!battle) throw new Error("Battle not found");

        if (battle.mode === 'ghost') {
            if (battle.challengerId !== userId) {
              throw new Error("Not your battle");
            }
            
            const isWin = args.score > (battle.opponentScore || 0);

            await ctx.db.patch(args.battleId, {
                challengerScore: args.score,
                status: "completed",
                winnerId: isWin ? userId : battle.opponentId
            });

            // Revenge Notification Logic (Only if you won)
            if (isWin) {
                const diff = args.score - (battle.opponentScore || 0);
                
                await ctx.db.insert("notifications", {
                    userId: battle.opponentId, // The Ghost Owner
                    type: "revenge",
                    data: {
                        senderId: userId,
                        gameId: battle.gameId,
                        battleId: battle._id,
                        amount: diff,
                        message: `beat your score by ${diff} points!`
                    },
                    read: false,
                    createdAt: Date.now()
                });
            }
            
            return { 
              success: true, 
              win: isWin, 
              opponentScore: battle.opponentScore || 0 
            };
        }
        
        return { success: true };
    }
});

Revenge Notifications

When you defeat someone’s ghost, they receive a revenge notification:
// From convex/schema.ts:88-100
notifications: defineTable({
  userId: v.id("users"),
  type: v.union(
    v.literal("revenge"), 
    v.literal("friend_request"), 
    v.literal("battle_invite")
  ),
  data: v.object({
      senderId: v.optional(v.id("users")),
      gameId: v.optional(v.string()),
      message: v.optional(v.string()),
      battleId: v.optional(v.id("battles")),
      amount: v.optional(v.number()), // XP gap or time difference
  }),
  read: v.boolean(),
  createdAt: v.number(),
})

Notification Example

Revenge Available!

Alex Martinez beat your score by 260 points in Function Fury![Challenge Them Back →]

Fetching Notifications

// From convex/social.ts:331-358
export const getNotifications = query({
    args: {},
    handler: async (ctx) => {
        const userId = await getAuthUserId(ctx);
        if (!userId) return [];
        
        const notifs = await ctx.db
            .query("notifications")
            .withIndex("by_user_read", (q) => 
              q.eq("userId", userId).eq("read", false)
            )
            .order("desc")
            .collect();

        // Enhance with sender info
        return await Promise.all(notifs.map(async (n) => {
            let senderName = "System";
            let senderImage = undefined;
            if (n.data.senderId) {
                const sender = await ctx.db.get(n.data.senderId);
                if (sender) {
                    senderName = sender.name || "Anonymous";
                    senderImage = sender.image;
                }
            }
            return { ...n, senderName, senderImage };
        }));
    }
});

Revenge Loops

Ghost Mode creates addictive revenge cycles:
1

Initial Challenge

You challenge Sarah to Function Fury. Her ghost scores 1,240.
2

You Win

You score 1,500 and beat her by 260 points. She gets a revenge notification.
3

Revenge Challenge

Sarah sees the notification and challenges your new ghost (1,500 points).
4

Sarah Wins

She scores 1,620 and reclaims her title. Now you get the revenge notification.
5

Infinite Loop

The cycle continues as both players push each other to improve.
Revenge loops naturally improve player skill. Each cycle raises the skill ceiling as players optimize their strategies to beat each other.

Guest User Restrictions

Guest users (anonymous accounts) cannot access Ghost Mode or any social features.
// From convex/social.ts:7-13
async function checkNotAnonymous(ctx: any, userId: any) {
    const user = await ctx.db.get(userId);
    if (!user || user.isAnonymous) {
        return false;
    }
    return true;
}
Guest restrictions apply to:
  • Creating ghost battles
  • Sending friend requests
  • Viewing notifications
  • Accessing friends activity feed

Ghost Mode vs. Live Battles

FeatureGhost ModeLive Battles
SchedulingNo coordination neededBoth players must be online
OpponentBest recorded scoreReal-time human opponent
StatusStarts "active"Starts "pending" until accepted
NotificationsRevenge notifications onlyBattle invites required
FlexibilityPlay anytimeRequires acceptance
IntensityCasual competitiveHigh-stakes real-time
Most CodeJam battles are Ghost Mode. Live battles are reserved for scheduled tournaments and friend sessions.

Strategy Tips

Preview opponent scores before challenging. Target friends whose scores are just above yours for achievable wins.
If you hold the top score in a game mode, expect revenge challenges. Keep practicing to defend your title.
Beating someone by a huge margin (500+ points) increases the likelihood they’ll attempt revenge, creating engagement.
Monitor which of your ghost scores get beaten most often. These are your weakest modes - focus training there.

Implementation Notes

Ghost Mode implementation details:
  • Score Locking: Opponent scores are locked when the battle is created (convex/social.ts:231-238)
  • Baseline Score: If opponent has no stats, default to 50 points (convex/social.ts:238)
  • Revenge Threshold: Notifications only trigger on challenger wins (convex/social.ts:304)
  • Battle Lifecycle: Ghost battles skip the "pending" state entirely (convex/social.ts:245)

Social Features

Learn about friends, notifications, and presence

Game Modes

Explore all available game modes for battles

Build docs developers (and LLMs) love