Skip to main content

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.
opponentId
Id<'users'>
required
User ID of the opponent
gameId
string
required
Game to compete in (e.g., “syntax-smasher”)
mode
'live' | 'ghost'
required
Battle mode
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:
  1. The opponent’s bestScore is fetched from game_stats
  2. If no score exists, a baseline score of 50 is used
  3. Battle status is immediately set to active
  4. 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.
battleId
Id<'battles'>
required
Battle ID from createBattle
score
number
required
Your final score
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).
gameId
string
required
Game identifier
score
number
required
Score achieved
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.
battleId
string
required
Battle ID
const battle = useQuery(api.social.getBattle, { 
  battleId: "k456def" 
});
Returns: Battle | null

getGhostScore

Get a player’s best score for a specific game (used when creating ghost battles).
userId
Id<'users'>
required
Player’s user ID
gameId
string
required
Game identifier
const ghostScore = useQuery(api.social.getGhostScore, {
  userId: "k123abc",
  gameId: "syntax-smasher"
});
// Returns: 1234 (or 0 if never played)
Returns: number

Battle Status Flow

1

Pending

Live battles start in pending status, waiting for opponent to accept.Ghost battles skip this and go directly to active.
2

Active

Battle is in progress. Players can submit scores.
3

Completed

Battle finished with a winner determined.Winner is set based on who has the higher score.
4

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>
  );
}

Build docs developers (and LLMs) love