Overview
CodeJam features two battle modes:
Live Battles : Real-time 1v1 competitions (coming soon)
Ghost Battles : Compete against another player’s best score asynchronously
Battles have a complete lifecycle from creation to completion with winner determination.
Battle Modes
Ghost Mode Play against a recorded best score. The opponent’s score is locked in when the battle is created. You can play immediately without waiting for the opponent.
Live Mode Real-time battles where both players compete simultaneously (status: pending until accepted).
Mutations
createBattle
Challenge another player to a battle.
Game to compete in (e.g., “syntax-smasher”)
import { useMutation } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
const createBattle = useMutation ( api . social . createBattle );
const result = await createBattle ({
opponentId: "k123abc" ,
gameId: "syntax-smasher" ,
mode: "ghost"
});
if ( result . error ) {
console . error ( result . error );
} else {
console . log ( "Battle ID:" , result . battleId );
}
Returns : { success: true, battleId: Id<'battles'> } | { error: string }
Guest users cannot create battles. Anonymous users will receive an error: “Guest users cannot access social features. Please sign up.”
Ghost Mode Behavior
When creating a ghost battle:
The opponent’s bestScore is fetched from game_stats
If no score exists, a baseline score of 50 is used
Battle status is immediately set to active
You can start playing right away
// Ghost battle logic from source
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 ();
opponentScore = stats ?. bestScore || 50 ;
}
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 (),
});
finishBattle
Complete a battle by submitting your score.
Battle ID from createBattle
const finishBattle = useMutation ( api . social . finishBattle );
const result = await finishBattle ({
battleId: "k456def" ,
score: 1250
});
if ( result . win ) {
console . log ( "You won! Opponent scored:" , result . opponentScore );
} else {
console . log ( "You lost. Better luck next time!" );
}
Returns : { success: true, win: boolean, opponentScore: number }
Winner Determination
For ghost battles:
const isWin = args . score > ( battle . opponentScore || 0 );
await ctx . db . patch ( args . battleId , {
challengerScore: args . score ,
status: "completed" ,
winnerId: isWin ? userId : battle . opponentId
});
Revenge Notifications
When you win a ghost battle, the opponent receives a revenge notification:
if ( isWin ) {
const diff = args . score - ( battle . opponentScore || 0 );
await ctx . db . insert ( "notifications" , {
userId: battle . opponentId ,
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 ()
});
}
Notifications allow the opponent to challenge you back for revenge!
updateGameStats
Update your best score for a game (called after completing any game).
const updateStats = useMutation ( api . social . updateGameStats );
await updateStats ({
gameId: "function-fury" ,
score: 1500
});
This mutation:
Creates a new game_stats record if this is your first play
Updates bestScore (only if higher than previous)
Increments gamesPlayed counter
Updates lastPlayed timestamp
Queries
getBattle
Fetch details of a specific battle.
const battle = useQuery ( api . social . getBattle , {
battleId: "k456def"
});
Returns : Battle | null
User who initiated the battle
status
'pending' | 'active' | 'completed' | 'rejected'
Current battle status
Opponent’s score (locked in for ghost mode)
Battle creation timestamp
getGhostScore
Get a player’s best score for a specific game (used when creating ghost battles).
const ghostScore = useQuery ( api . social . getGhostScore , {
userId: "k123abc" ,
gameId: "syntax-smasher"
});
// Returns: 1234 (or 0 if never played)
Returns : number
Battle Status Flow
Pending
Live battles start in pending status, waiting for opponent to accept. Ghost battles skip this and go directly to active.
Active
Battle is in progress. Players can submit scores.
Completed
Battle finished with a winner determined. Winner is set based on who has the higher score.
Rejected
Opponent declined the battle (live mode only).
Example: Complete Battle Flow
import { useQuery , useMutation } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
import { useState } from "react" ;
function GhostBattle ({ opponentId } : { opponentId : string }) {
const [ battleId , setBattleId ] = useState < string | null >( null );
const [ finalScore , setFinalScore ] = useState < number >( 0 );
const createBattle = useMutation ( api . social . createBattle );
const finishBattle = useMutation ( api . social . finishBattle );
const updateStats = useMutation ( api . social . updateGameStats );
const battle = useQuery (
api . social . getBattle ,
battleId ? { battleId } : "skip"
);
const ghostScore = useQuery ( api . social . getGhostScore , {
userId: opponentId ,
gameId: "syntax-smasher"
});
const handleStartBattle = async () => {
const result = await createBattle ({
opponentId ,
gameId: "syntax-smasher" ,
mode: "ghost"
});
if ( result . error ) {
alert ( result . error );
} else {
setBattleId ( result . battleId );
}
};
const handleFinish = async () => {
if ( ! battleId ) return ;
// Update personal stats
await updateStats ({
gameId: "syntax-smasher" ,
score: finalScore
});
// Finish battle
const result = await finishBattle ({
battleId ,
score: finalScore
});
if ( result . win ) {
alert ( `You won by ${ finalScore - result . opponentScore } points!` );
} else {
alert ( "Better luck next time!" );
}
};
if ( ! battle ) {
return (
< div >
< p > Ghost Score to Beat : { ghostScore }</ p >
< button onClick = { handleStartBattle } > Start Battle </ button >
</ div >
);
}
if ( battle . status === "completed" ) {
return (
< div >
< h2 >{battle. winnerId === battle . challengerId ? "Victory!" : "Defeat" } </ h2 >
< p > Your Score : { battle . challengerScore }</ p >
< p > Ghost Score : { battle . opponentScore }</ p >
</ div >
);
}
return (
< div >
< h2 > Battle in Progress </ h2 >
< p > Target : { battle . opponentScore }</ p >
< input
type = "number"
value = { finalScore }
onChange = {(e) => setFinalScore ( Number (e.target.value))}
/>
< button onClick = { handleFinish } > Submit Score </ button >
</ div >
);
}