-
+
+
Score submitted!
+ {/* If they submitted successfully but aren't signed in, prompt them to claim it */}
+ {!isAuthenticated && (
+
+
Want to claim "{playerName}" permanently?
+
+
+ )}
)}
diff --git a/convex/auth.config.ts b/convex/auth.config.ts
new file mode 100644
index 0000000..1874069
--- /dev/null
+++ b/convex/auth.config.ts
@@ -0,0 +1,8 @@
+export default {
+ providers: [
+ {
+ domain: process.env.CONVEX_SITE_URL,
+ applicationID: "convex",
+ },
+ ],
+};
\ No newline at end of file
diff --git a/convex/auth.ts b/convex/auth.ts
new file mode 100644
index 0000000..86be0dc
--- /dev/null
+++ b/convex/auth.ts
@@ -0,0 +1,6 @@
+import Google from "@auth/core/providers/google";
+import { convexAuth } from "@convex-dev/auth/server";
+
+export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
+ providers: [Google],
+});
\ No newline at end of file
diff --git a/convex/http.ts b/convex/http.ts
new file mode 100644
index 0000000..4dfcb76
--- /dev/null
+++ b/convex/http.ts
@@ -0,0 +1,8 @@
+import { httpRouter } from "convex/server";
+import { auth } from "./auth";
+
+const http = httpRouter();
+
+auth.addHttpRoutes(http);
+
+export default http;
\ No newline at end of file
diff --git a/convex/leaderboard.ts b/convex/leaderboard.ts
index cc7e51a..0d3e54a 100644
--- a/convex/leaderboard.ts
+++ b/convex/leaderboard.ts
@@ -72,6 +72,8 @@ export const listBoards = query({
},
});
+import { getAuthUserId } from "@convex-dev/auth/server";
+
export const submitScore = mutation({
args: {
playerName: v.string(),
@@ -86,8 +88,29 @@ export const submitScore = mutation({
throw new Error("Player name cannot be empty");
}
+ const userId = await getAuthUserId(ctx);
+
+ // If they are not logged in, ensure the name is not claimed
+ if (userId === null) {
+ const existingUser = await ctx.db
+ .query("users")
+ .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
+ .first();
+
+ if (existingUser !== null) {
+ throw new Error("This name is already claimed by a registered user. Please sign in or choose another name.");
+ }
+ } else {
+ // If logged in, they should only be submitting scores under their claimed name
+ const user = await ctx.db.get(userId);
+ if (user && user.customDisplayName && user.customDisplayName !== trimmedName) {
+ throw new Error("You must submit scores under your claimed name, or change your name.");
+ }
+ }
+
await ctx.db.insert("leaderboard", {
playerName: trimmedName,
+ userId: userId !== null ? userId : undefined,
gameMode: args.gameMode,
settingsHash: args.settingsHash,
longestCombo: args.longestCombo,
diff --git a/convex/schema.ts b/convex/schema.ts
index d51dd2a..3851066 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -1,9 +1,22 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
+import { authTables } from "@convex-dev/auth/server";
export default defineSchema({
+ ...authTables,
+ users: defineTable({
+ name: v.optional(v.string()),
+ image: v.optional(v.string()),
+ email: v.optional(v.string()),
+ emailVerificationTime: v.optional(v.number()),
+ phone: v.optional(v.string()),
+ phoneVerificationTime: v.optional(v.number()),
+ isAnonymous: v.optional(v.boolean()),
+ customDisplayName: v.optional(v.string()),
+ }).index("email", ["email"]).index("customDisplayName", ["customDisplayName"]),
leaderboard: defineTable({
playerName: v.string(),
+ userId: v.optional(v.id("users")),
gameMode: v.string(), // "Classic" or "Custom"
settingsHash: v.string(), // "classic" for Classic, deterministic key for Custom
longestCombo: v.number(),
@@ -11,5 +24,7 @@ export default defineSchema({
completedAt: v.number(), // timestamp
})
.index("by_settings", ["settingsHash", "longestCombo"])
- .index("by_mode", ["gameMode", "longestCombo"]),
+ .index("by_mode", ["gameMode", "longestCombo"])
+ .index("by_userId", ["userId"])
+ .index("by_playerName", ["playerName"]),
});
diff --git a/convex/users.ts b/convex/users.ts
new file mode 100644
index 0000000..d2915a8
--- /dev/null
+++ b/convex/users.ts
@@ -0,0 +1,88 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+import { getAuthUserId } from "@convex-dev/auth/server";
+
+export const checkNameClaimed = query({
+ args: {
+ name: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const trimmedName = args.name.trim().slice(0, 20);
+ const existingUser = await ctx.db
+ .query("users")
+ .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
+ .first();
+
+ return existingUser !== null;
+ },
+});
+
+export const claimName = mutation({
+ args: {
+ name: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (userId === null) {
+ throw new Error("User must be authenticated to claim a name.");
+ }
+
+ const trimmedName = args.name.trim().slice(0, 20);
+ if (trimmedName.length === 0) {
+ throw new Error("Name cannot be empty");
+ }
+
+ // Check if another user already claimed this name
+ const existingUser = await ctx.db
+ .query("users")
+ .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
+ .first();
+
+ if (existingUser !== null && existingUser._id !== userId) {
+ throw new Error("This name is already claimed by someone else.");
+ }
+
+ // Update the user's customDisplayName
+ await ctx.db.patch(userId, { customDisplayName: trimmedName, isAnonymous: false });
+
+ // Migrate existing anonymous scores under this name to the user
+ const allScoresForName = await ctx.db
+ .query("leaderboard")
+ .withIndex("by_playerName", (q) => q.eq("playerName", trimmedName))
+ .collect();
+
+ for (const score of allScoresForName) {
+ if (score.userId === undefined) {
+ await ctx.db.patch(score._id, { userId });
+ }
+ }
+
+ // If the user previously had a different customDisplayName, do we migrate those scores?
+ // Actually, scores submitted when authenticated are stored with userId.
+ // The playername stored on the record will be historical or we can update it.
+ // Let's update the playername on all their past scores to the new name.
+ const allUserScores = await ctx.db
+ .query("leaderboard")
+ .withIndex("by_userId", (q) => q.eq("userId", userId))
+ .collect();
+
+ for (const score of allUserScores) {
+ if (score.playerName !== trimmedName) {
+ await ctx.db.patch(score._id, { playerName: trimmedName });
+ }
+ }
+ },
+});
+
+export const currentUserDetails = query({
+ args: {},
+ handler: async (ctx) => {
+ const userId = await getAuthUserId(ctx);
+ if (userId === null) return null;
+ const user = await ctx.db.get(userId);
+ return {
+ name: user?.customDisplayName || null,
+ image: user?.image || null,
+ };
+ }
+});
\ No newline at end of file
diff --git a/index.tsx b/index.tsx
index 0480497..ee18bb5 100644
--- a/index.tsx
+++ b/index.tsx
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import { ConvexProvider, ConvexReactClient } from "convex/react";
+import { ConvexReactClient } from "convex/react";
+import { ConvexAuthProvider } from "@convex-dev/auth/react";
import App from "./App";
import "./global.css";
import { inject } from "@vercel/analytics";
@@ -20,9 +21,9 @@ if (convexUrl) {
const convex = new ConvexReactClient(convexUrl);
root.render(
-
+
-
+
,
);
} else {
diff --git a/package-lock.json b/package-lock.json
index ddbb3a9..5a9a756 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "matchfield",
"version": "0.0.0",
"dependencies": {
+ "@auth/core": "^0.37.0",
+ "@convex-dev/auth": "^0.0.91",
"@tailwindcss/vite": "^4.1.16",
"@vercel/analytics": "^1.5.0",
"canvas-confetti": "^1.9.4",
@@ -24,6 +26,46 @@
"vite": "^6.4.1"
}
},
+ "node_modules/@auth/core": {
+ "version": "0.37.0",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.0.tgz",
+ "integrity": "sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "@types/cookie": "0.6.0",
+ "cookie": "0.7.1",
+ "jose": "^5.9.3",
+ "oauth4webapi": "^3.0.0",
+ "preact": "10.11.3",
+ "preact-render-to-string": "5.2.3"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^6.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@auth/core/node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -306,6 +348,37 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@convex-dev/auth": {
+ "version": "0.0.91",
+ "resolved": "https://registry.npmjs.org/@convex-dev/auth/-/auth-0.0.91.tgz",
+ "integrity": "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@oslojs/crypto": "^1.0.1",
+ "@oslojs/encoding": "^1.1.0",
+ "cookie": "^1.0.1",
+ "is-network-error": "^1.1.0",
+ "jose": "^5.2.2",
+ "jwt-decode": "^4.0.0",
+ "lucia": "^3.2.0",
+ "oauth4webapi": "^3.1.2",
+ "path-to-regexp": "^6.3.0",
+ "server-only": "^0.0.1"
+ },
+ "bin": {
+ "auth": "dist/bin.cjs"
+ },
+ "peerDependencies": {
+ "@auth/core": "^0.37.0",
+ "convex": "^1.17.0",
+ "react": "^18.2.0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
@@ -767,6 +840,46 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@oslojs/asn1": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
+ "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oslojs/binary": "1.0.0"
+ }
+ },
+ "node_modules/@oslojs/binary": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz",
+ "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@oslojs/crypto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz",
+ "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@oslojs/asn1": "1.0.0",
+ "@oslojs/binary": "1.0.0"
+ }
+ },
+ "node_modules/@oslojs/encoding": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
+ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
+ "license": "MIT"
+ },
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1401,6 +1514,12 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1595,6 +1714,19 @@
}
}
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1740,6 +1872,18 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/is-network-error": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
+ "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1749,6 +1893,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1782,6 +1935,15 @@
"node": ">=6"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -2041,6 +2203,17 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucia": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/lucia/-/lucia-3.2.2.tgz",
+ "integrity": "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==",
+ "deprecated": "This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.",
+ "license": "MIT",
+ "dependencies": {
+ "@oslojs/crypto": "^1.0.1",
+ "@oslojs/encoding": "^1.1.0"
+ }
+ },
"node_modules/lucide-react": {
"version": "0.552.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.552.0.tgz",
@@ -2091,6 +2264,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oauth4webapi": {
+ "version": "3.8.6",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
+ "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2137,6 +2325,28 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/preact": {
+ "version": "10.11.3",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
+ "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
+ "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
+ "license": "MIT",
+ "dependencies": {
+ "pretty-format": "^3.8.0"
+ },
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
@@ -2152,6 +2362,12 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-format": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
+ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
+ "license": "MIT"
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -2243,6 +2459,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/server-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/package.json b/package.json
index 6ca0efd..22e8629 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@auth/core": "^0.37.0",
+ "@convex-dev/auth": "^0.0.91",
"@tailwindcss/vite": "^4.1.16",
"@vercel/analytics": "^1.5.0",
"canvas-confetti": "^1.9.4",