Skip to main content
CodeJam’s social ecosystem transforms coding practice into a multiplayer experience. Add friends, send battle challenges, receive revenge notifications, and track real-time activity.
Guest users (anonymous accounts) cannot access social features. Full account registration required.

Social Features Overview

Friends System

Add friends by email or user ID. Accept/reject requests.

Battle Invites

Challenge friends to live or ghost battles in any game mode.

Notifications

Receive revenge alerts, friend requests, and battle invites.

Presence System

Real-time online/offline status with lastSeen timestamps.

Friends Schema

// From convex/schema.ts:57-66
friends: defineTable({
  user1: v.id("users"),
  user2: v.id("users"),
  status: v.union(v.literal("pending"), v.literal("active")),
  initiatedBy: v.id("users"),
})
.index("by_user1", ["user1"])
.index("by_user2", ["user2"])
.index("by_status", ["status"])
.index("by_pair", ["user1", "user2"])

Friendship States

  • pending - Friend request sent but not yet accepted
  • active - Friendship confirmed, both users can interact

Bidirectional Lookups

Friendships are stored as directed edges but queried bidirectionally:
// Must check both user1 and user2 positions
const friends1 = await ctx.db
  .query("friends")
  .withIndex("by_user1", (q) => q.eq("user1", userId))
  .collect();

const friends2 = await ctx.db
  .query("friends")
  .withIndex("by_user2", (q) => q.eq("user2", userId))
  .collect();

Sending Friend Requests

// From convex/social.ts:18-65
export const sendFriendRequest = mutation({
  args: { 
    targetId: v.optional(v.id("users")), 
    targetEmail: v.optional(v.string()) 
  }, 
  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. Please sign up." 
        };
    }

    let targetUser = null;

    // Find user by ID or email
    if (args.targetId) {
        targetUser = await ctx.db.get(args.targetId);
    } else if (args.targetEmail) {
        targetUser = await ctx.db
          .query("users")
          .withIndex("email", (q) => q.eq("email", args.targetEmail!))
          .first();
    }

    if (!targetUser) return { error: "User not found" };
    if (targetUser._id === userId) return { error: "Cannot add yourself" };

    // Check if relation exists (both directions)
    const existing1 = await ctx.db
      .query("friends")
      .withIndex("by_pair", (q) => 
        q.eq("user1", userId).eq("user2", targetUser._id)
      )
      .first();
    
    const existing2 = await ctx.db
      .query("friends")
      .withIndex("by_pair", (q) => 
        q.eq("user1", targetUser._id).eq("user2", userId)
      )
      .first();

    if (existing1 || existing2) {
      return { error: "Request already sent or friends" };
    }

    await ctx.db.insert("friends", {
      user1: userId,
      user2: targetUser._id,
      status: "pending",
      initiatedBy: userId,
    });
    
    return { success: true };
  },
});
Users can send friend requests by email OR user ID, making it easy to find friends who just signed up.

Accepting Friend Requests

// From convex/social.ts:68-84
export const acceptFriendRequest = mutation({
  args: { friendshipId: v.id("friends") },
  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." };
    }

    const friendship = await ctx.db.get(args.friendshipId);
    if (!friendship) return { error: "Friendship not found" };
    if (friendship.user2 !== userId) {
      return { error: "Not authorized to accept" };
    }

    await ctx.db.patch(args.friendshipId, { status: "active" });
    return { success: true };
  },
});
Only the recipient (user2) can accept a friend request. This prevents the sender from auto-accepting their own requests.

Rejecting/Removing Friends

// From convex/social.ts:86-105
export const rejectFriendRequest = mutation({
  args: { friendshipId: v.id("friends") },
  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." };
    }

    const friendship = await ctx.db.get(args.friendshipId);
    if (!friendship) return { error: "Not found" };
    
    if (friendship.user1 !== userId && friendship.user2 !== userId) {
        return { error: "Unauthorized" };
    }

    await ctx.db.delete(args.friendshipId);
    return { success: true };
  },
});
Both users can delete the friendship. There’s no distinction between “reject” and “unfriend” - both permanently delete the relationship.

Fetching Friends List

// From convex/social.ts:160-199
export const getFriends = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return [];

    // Fetch relations where user is user1 or user2
    const friends1 = await ctx.db
      .query("friends")
      .withIndex("by_user1", (q) => q.eq("user1", userId))
      .collect();

    const friends2 = await ctx.db
      .query("friends")
      .withIndex("by_user2", (q) => q.eq("user2", userId))
      .collect();

    const allFriendships = [...friends1, ...friends2];
    
    // Resolve user details
    const friendsWithDetails = await Promise.all(
      allFriendships.map(async (f) => {
        const otherUserId = f.user1 === userId ? f.user2 : f.user1;
        const user = await ctx.db.get(otherUserId);
        
        return {
          _id: f._id,
          friendId: otherUserId,
          name: user?.name || "Anonymous",
          image: user?.customAvatar || user?.image,
          status: f.status,
          lastSeen: user?.lastSeen,
          initiatedByMe: f.initiatedBy === userId
        };
      })
    );

    return friendsWithDetails;
  },
});

Friends Activity Feed

Track what your friends are doing:
// From convex/social.ts:107-158
export const getFriendsActivity = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return [];

    // Get all active friend IDs
    const friends1 = await ctx.db
      .query("friends")
      .withIndex("by_user1", (q) => q.eq("user1", userId))
      .filter((q) => q.eq(q.field("status"), "active"))
      .collect();

    const friends2 = await ctx.db
      .query("friends")
      .withIndex("by_user2", (q) => q.eq("user2", userId))
      .filter((q) => q.eq(q.field("status"), "active"))
      .collect();

    const friendIds = [
        ...friends1.map(f => f.user2),
        ...friends2.map(f => f.user1)
    ];

    if (friendIds.length === 0) return [];

    // Fetch latest activity for each friend
    const activities = await Promise.all(
        friendIds.map(async (fid) => {
            const logs = await ctx.db
                .query("activity_logs")
                .withIndex("by_user", (q) => q.eq("userId", fid))
                .order("desc")
                .take(1);
            
            if (logs.length === 0) return null;
            
            const user = await ctx.db.get(fid);
            return {
                ...logs[0],
                userName: user?.name || "Unknown",
                userImage: user?.customAvatar || user?.image,
            };
        })
    );

    return activities
        .filter((a): a is NonNullable<typeof a> => a !== null)
        .sort((a, b) => b.timestamp - a.timestamp)
        .slice(0, 10);
  }
});

Activity Feed UI

Friends Activity

Sarah Chen completed Function Fury • +150 XP • 2 min ago
Alex Martinez earned Speed Demon badge • +200 XP • 5 min ago
Jamie Lee defeated CSS Combat boss • +500 XP • 12 min ago

Notifications Schema

// 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(),
}).index("by_user_read", ["userId", "read"])

Notification Types

Triggered when someone beats your ghost score in Ghost Mode.Data:
  • senderId - Who beat your score
  • gameId - Which game mode
  • battleId - Reference to the battle
  • amount - XP difference
  • message - “beat your score by X points!”
Triggered when someone sends you a friend request.Data:
  • senderId - Who sent the request
  • message - Optional custom message
Triggered when someone challenges you to a live battle.Data:
  • senderId - Who sent the challenge
  • gameId - Proposed game mode
  • battleId - Reference to pending battle

Fetching Notifications

// From convex/social.ts:331-358
export const getNotifications = query({
    args: {},
    handler: async (ctx) => {
        const userId = await getAuthUserId(ctx);
        if (!userId) return [];
        
        // Fetch unread notifications
        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 };
        }));
    }
});

Marking Notifications as Read

// From convex/social.ts:360-371
export const markRead = mutation({
    args: { notificationId: v.id("notifications") },
    handler: async (ctx, args) => {
        const userId = await getAuthUserId(ctx);
        if (!userId) throw new Error("Unauthorized");
        
        const n = await ctx.db.get(args.notificationId);
        if (n?.userId !== userId) throw new Error("Unauthorized");

        await ctx.db.patch(args.notificationId, { read: true });
    }
});
Notifications are never deleted, only marked as read. This allows users to review notification history.

Real-Time Presence System

User presence is tracked via the lastSeen field:
// From convex/schema.ts:7-25
users: defineTable({
  name: v.optional(v.string()),
  image: v.optional(v.string()),
  email: v.optional(v.string()),
  // ...
  lastSeen: v.optional(v.number()), // Timestamp for presence
  // ...
})

Updating Presence

Clients should periodically update lastSeen during active sessions:
export const updatePresence = mutation({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthorized");
    
    await ctx.db.patch(userId, { lastSeen: Date.now() });
  }
});

Calculating Online Status

In the UI, consider users online if lastSeen is within the last 5 minutes:
const isOnline = (lastSeen?: number) => {
  if (!lastSeen) return false;
  const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
  return lastSeen > fiveMinutesAgo;
};

Guest User Restrictions

Anonymous users cannot access ANY social features to prevent spam and abuse.
// 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;
}
Restricted features for guests:
  • Friend requests
  • Battle creation (Ghost or Live)
  • Notifications
  • Activity feed
  • Presence updates

Battle Integration

Social features integrate with the battle system:
// 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." };
    }

    // ... battle creation logic
  },
});
See the Ghost Mode documentation for complete battle implementation details.

Social Features Architecture

1

User Authentication

User logs in via Convex Auth. Guest mode available but restricted.
2

Friend Discovery

Users find friends by email or user ID search.
3

Relationship Established

Friend request sent, stored as status: "pending". Recipient accepts to make it "active".
4

Activity Tracking

All user actions (XP earned, games completed) are logged to activity_logs.
5

Feed Generation

getFriendsActivity aggregates recent logs from all active friends.
6

Real-Time Updates

Convex subscriptions push live updates to clients as activities occur.

Implementation Best Practices

Always query both by_user1 and by_user2 indexes when fetching friendships. Users can appear in either position.
Prevent duplicate notifications by checking if a similar notification already exists before inserting.
Don’t update lastSeen on every user action. Throttle to once per minute to reduce database load.
The activity feed uses .slice(0, 10) to limit results. Implement pagination for users with many friends.

Ghost Mode

Asynchronous battles between friends

Game Modes

Competitive challenges for battle invites

Build docs developers (and LLMs) love