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!”
Friend Request Notifications
Triggered when someone sends you a friend request. Data:
senderId - Who sent the request
message - Optional custom message
Battle Invite Notifications
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
User Authentication
User logs in via Convex Auth. Guest mode available but restricted.
Friend Discovery
Users find friends by email or user ID search.
Relationship Established
Friend request sent, stored as status: "pending". Recipient accepts to make it "active".
Activity Tracking
All user actions (XP earned, games completed) are logged to activity_logs.
Feed Generation
getFriendsActivity aggregates recent logs from all active friends.
Real-Time Updates
Convex subscriptions push live updates to clients as activities occur.
Implementation Best Practices
Bidirectional Friendship Queries
Always query both by_user1 and by_user2 indexes when fetching friendships. Users can appear in either position.
Notification Deduplication
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.
Ghost Mode Asynchronous battles between friends
Game Modes Competitive challenges for battle invites