Spaces:
Running
Running
Abhinay Claude Opus 4.7 (1M context) commited on
Commit ·
810ed38
1
Parent(s): 9e5f6f6
Add VITE_API_BASE + HF Space and Vercel deploy configs
Browse filesSplit-deploy support so backend can live on a Hugging Face Space (Docker
SDK) and frontend on Vercel. All fetch/EventSource calls now go through
src/api.js which prepends VITE_API_BASE at build time; falls back to
relative URLs for dev (Vite proxy) and monolithic deploys (Express serves
dist/).
- README.md frontmatter for HF Space (sdk: docker, app_port: 8080)
- vercel.json for the Vite frontend (rewrite /controller to index.html)
- .env.example documents every env var
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .env.example +11 -0
- README.md +36 -15
- src/App.jsx +3 -2
- src/Controller.jsx +3 -2
- src/Game.jsx +4 -3
- src/Leaderboard.jsx +2 -1
- src/MobileGame.jsx +2 -1
- src/UsernameGate.jsx +2 -1
- src/api.js +10 -0
- vercel.json +9 -0
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- backend (server/server.js) ---
|
| 2 |
+
MONGO_URI=mongodb+srv://<user>:<pass>@<cluster>/news?retryWrites=true&w=majority
|
| 3 |
+
MONGO_DB=news
|
| 4 |
+
MONGO_COLLECTION=egg_catcher_scores
|
| 5 |
+
# PORT is set by the host (HF Space, Fly, etc.); defaults to 5174 locally.
|
| 6 |
+
|
| 7 |
+
# --- frontend build (vite) ---
|
| 8 |
+
# Empty in monolithic deploy (frontend served by same Express).
|
| 9 |
+
# In split deploy, set to your backend URL (no trailing slash), e.g.:
|
| 10 |
+
# VITE_API_BASE=https://abhinay-egg-catcher-api.hf.space
|
| 11 |
+
VITE_API_BASE=
|
README.md
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# egg-catcher
|
| 2 |
|
| 3 |
Pixel-art egg-catcher arcade. Laptop = main screen with a QR pop-up; phone = controller. MongoDB-backed global leaderboard.
|
| 4 |
|
|
|
|
|
|
|
| 5 |
## Run locally
|
| 6 |
|
| 7 |
```bash
|
|
@@ -11,7 +24,7 @@ npm run dev
|
|
| 11 |
|
| 12 |
`http://localhost:5173/` on the laptop. Phone scans the QR (same wifi).
|
| 13 |
|
| 14 |
-
`.env` (
|
| 15 |
|
| 16 |
```
|
| 17 |
MONGO_URI=mongodb+srv://...
|
|
@@ -19,26 +32,34 @@ MONGO_DB=news
|
|
| 19 |
MONGO_COLLECTION=egg_catcher_scores
|
| 20 |
```
|
| 21 |
|
| 22 |
-
## Deploy
|
| 23 |
|
| 24 |
-
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
``
|
| 32 |
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
## How it works
|
| 40 |
|
| 41 |
-
- Single Express server: serves
|
| 42 |
- Game rooms are in-memory; SSE relays controller events laptop ⇄ phone.
|
| 43 |
- `/api/score`, `/api/leaderboard` write to MongoDB collection `egg_catcher_scores`.
|
| 44 |
-
-
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Egg Catcher
|
| 3 |
+
emoji: 🥚
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8080
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: Pixel-art arcade with phone-as-controller and MongoDB leaderboard
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
# egg-catcher
|
| 13 |
|
| 14 |
Pixel-art egg-catcher arcade. Laptop = main screen with a QR pop-up; phone = controller. MongoDB-backed global leaderboard.
|
| 15 |
|
| 16 |
+
The frontmatter above is for [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces-config-reference) — it's ignored on GitHub.
|
| 17 |
+
|
| 18 |
## Run locally
|
| 19 |
|
| 20 |
```bash
|
|
|
|
| 24 |
|
| 25 |
`http://localhost:5173/` on the laptop. Phone scans the QR (same wifi).
|
| 26 |
|
| 27 |
+
`.env` (gitignored) needs:
|
| 28 |
|
| 29 |
```
|
| 30 |
MONGO_URI=mongodb+srv://...
|
|
|
|
| 32 |
MONGO_COLLECTION=egg_catcher_scores
|
| 33 |
```
|
| 34 |
|
| 35 |
+
## Deploy — backend on HF Space, frontend on Vercel
|
| 36 |
|
| 37 |
+
### 1. Backend on Hugging Face
|
| 38 |
|
| 39 |
+
1. Go to https://huggingface.co/new-space
|
| 40 |
+
2. **Owner**: your account · **Space name**: e.g. `egg-catcher-api` · **License**: MIT · **Space SDK**: pick **Docker** → **Blank**
|
| 41 |
+
3. Hardware: **CPU basic — free**
|
| 42 |
+
4. Create. Then in the Space, **Settings** → **Variables and secrets** → add a *Secret* named `MONGO_URI` with your full Mongo connection string
|
| 43 |
+
5. **Settings** → **Repository** → **Connect to a GitHub repository** → pick `ABHINAY2025/egg-catcher`. Every push to `main` will rebuild the Space
|
| 44 |
+
6. The Space will build and come up at `https://<username>-<spacename>.hf.space`
|
| 45 |
|
| 46 |
+
### 2. Frontend on Vercel
|
| 47 |
|
| 48 |
+
1. Go to https://vercel.com/new
|
| 49 |
+
2. Import `ABHINAY2025/egg-catcher`
|
| 50 |
+
3. **Framework Preset**: Vite (auto-detected)
|
| 51 |
+
4. **Environment Variables**: add `VITE_API_BASE` = `https://<your-hf-space-url>` (no trailing slash, e.g. `https://abhinay-egg-catcher-api.hf.space`)
|
| 52 |
+
5. Deploy. Your game lives at `https://<project>.vercel.app`
|
| 53 |
+
|
| 54 |
+
### Why split?
|
| 55 |
+
|
| 56 |
+
- Vercel gives CDN-fast static delivery + instant SPA routing.
|
| 57 |
+
- HF Space stays warm with no card required and supports long-lived SSE for the phone-controller pairing.
|
| 58 |
+
- Frontend and backend are decoupled — swap either independently.
|
| 59 |
|
| 60 |
## How it works
|
| 61 |
|
| 62 |
+
- Single Express server: serves `/api/*` and (in monolithic deploy) the built Vite frontend.
|
| 63 |
- Game rooms are in-memory; SSE relays controller events laptop ⇄ phone.
|
| 64 |
- `/api/score`, `/api/leaderboard` write to MongoDB collection `egg_catcher_scores`.
|
| 65 |
+
- Controller URL in prod = `${window.location.origin}/controller?room=XXXXXX` (the frontend's URL, not the API).
|
src/App.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import UsernameGate from './UsernameGate.jsx';
|
|
| 3 |
import Game from './Game.jsx';
|
| 4 |
import MobileGame from './MobileGame.jsx';
|
| 5 |
import Leaderboard from './Leaderboard.jsx';
|
|
|
|
| 6 |
|
| 7 |
function detectMobile() {
|
| 8 |
if (typeof window === 'undefined') return false;
|
|
@@ -37,12 +38,12 @@ export default function App() {
|
|
| 37 |
let alive = true;
|
| 38 |
(async () => {
|
| 39 |
try {
|
| 40 |
-
const r1 = await fetch('/api/game/room', { method: 'POST' }).then((r) => r.json());
|
| 41 |
if (!alive) return;
|
| 42 |
setRoomId(r1.roomId);
|
| 43 |
if (import.meta.env.PROD) return; // in prod, controller URL = window.location.origin
|
| 44 |
try {
|
| 45 |
-
const r2 = await fetch('/api/game/local-ip').then((r) => r.json());
|
| 46 |
if (!alive) return;
|
| 47 |
setLanIp(r2.ip);
|
| 48 |
setIpCandidates(Array.isArray(r2.candidates) ? r2.candidates : []);
|
|
|
|
| 3 |
import Game from './Game.jsx';
|
| 4 |
import MobileGame from './MobileGame.jsx';
|
| 5 |
import Leaderboard from './Leaderboard.jsx';
|
| 6 |
+
import { api } from './api.js';
|
| 7 |
|
| 8 |
function detectMobile() {
|
| 9 |
if (typeof window === 'undefined') return false;
|
|
|
|
| 38 |
let alive = true;
|
| 39 |
(async () => {
|
| 40 |
try {
|
| 41 |
+
const r1 = await fetch(api('/api/game/room'), { method: 'POST' }).then((r) => r.json());
|
| 42 |
if (!alive) return;
|
| 43 |
setRoomId(r1.roomId);
|
| 44 |
if (import.meta.env.PROD) return; // in prod, controller URL = window.location.origin
|
| 45 |
try {
|
| 46 |
+
const r2 = await fetch(api('/api/game/local-ip')).then((r) => r.json());
|
| 47 |
if (!alive) return;
|
| 48 |
setLanIp(r2.ip);
|
| 49 |
setIpCandidates(Array.isArray(r2.candidates) ? r2.candidates : []);
|
src/Controller.jsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
|
| 3 |
const ROUND_MS = 60_000;
|
| 4 |
|
| 5 |
function send(roomId, action) {
|
| 6 |
-
fetch('/api/game/control', {
|
| 7 |
method: 'POST',
|
| 8 |
headers: { 'Content-Type': 'application/json' },
|
| 9 |
body: JSON.stringify({ roomId, action }),
|
|
@@ -28,7 +29,7 @@ export default function Controller() {
|
|
| 28 |
|
| 29 |
React.useEffect(() => {
|
| 30 |
if (!roomId) return;
|
| 31 |
-
const es = new EventSource(`/api/game/controller-events/${roomId}`);
|
| 32 |
es.addEventListener('ready', (e) => {
|
| 33 |
try { setPaired(JSON.parse(e.data).paired); } catch {}
|
| 34 |
});
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { api } from './api.js';
|
| 3 |
|
| 4 |
const ROUND_MS = 60_000;
|
| 5 |
|
| 6 |
function send(roomId, action) {
|
| 7 |
+
fetch(api('/api/game/control'), {
|
| 8 |
method: 'POST',
|
| 9 |
headers: { 'Content-Type': 'application/json' },
|
| 10 |
body: JSON.stringify({ roomId, action }),
|
|
|
|
| 29 |
|
| 30 |
React.useEffect(() => {
|
| 31 |
if (!roomId) return;
|
| 32 |
+
const es = new EventSource(api(`/api/game/controller-events/${roomId}`));
|
| 33 |
es.addEventListener('ready', (e) => {
|
| 34 |
try { setPaired(JSON.parse(e.data).paired); } catch {}
|
| 35 |
});
|
src/Game.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
|
| 3 |
const GAME_W = 640;
|
| 4 |
const GAME_H = 380;
|
|
@@ -15,7 +16,7 @@ const OMELET_TTL = 5500;
|
|
| 15 |
|
| 16 |
function postState(roomId, payload) {
|
| 17 |
if (!roomId) return;
|
| 18 |
-
fetch('/api/game/state', {
|
| 19 |
method: 'POST',
|
| 20 |
headers: { 'Content-Type': 'application/json' },
|
| 21 |
body: JSON.stringify({ roomId, ...payload }),
|
|
@@ -57,7 +58,7 @@ export default function Game({ username, onGameOver, roomId, lanIp, ipCandidates
|
|
| 57 |
// SSE — receive controller events (left/right/start)
|
| 58 |
React.useEffect(() => {
|
| 59 |
if (!roomId) return;
|
| 60 |
-
const es = new EventSource(`/api/game/events/${roomId}`);
|
| 61 |
es.addEventListener('ready', (e) => {
|
| 62 |
try { setPaired(!!JSON.parse(e.data).paired); } catch {}
|
| 63 |
});
|
|
@@ -111,7 +112,7 @@ export default function Game({ username, onGameOver, roomId, lanIp, ipCandidates
|
|
| 111 |
stateRef.current.running = false;
|
| 112 |
setRunning(false);
|
| 113 |
try {
|
| 114 |
-
const res = await fetch('/api/score', {
|
| 115 |
method: 'POST',
|
| 116 |
headers: { 'Content-Type': 'application/json' },
|
| 117 |
body: JSON.stringify({ username, score: finalScore }),
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { api } from './api.js';
|
| 3 |
|
| 4 |
const GAME_W = 640;
|
| 5 |
const GAME_H = 380;
|
|
|
|
| 16 |
|
| 17 |
function postState(roomId, payload) {
|
| 18 |
if (!roomId) return;
|
| 19 |
+
fetch(api('/api/game/state'), {
|
| 20 |
method: 'POST',
|
| 21 |
headers: { 'Content-Type': 'application/json' },
|
| 22 |
body: JSON.stringify({ roomId, ...payload }),
|
|
|
|
| 58 |
// SSE — receive controller events (left/right/start)
|
| 59 |
React.useEffect(() => {
|
| 60 |
if (!roomId) return;
|
| 61 |
+
const es = new EventSource(api(`/api/game/events/${roomId}`));
|
| 62 |
es.addEventListener('ready', (e) => {
|
| 63 |
try { setPaired(!!JSON.parse(e.data).paired); } catch {}
|
| 64 |
});
|
|
|
|
| 112 |
stateRef.current.running = false;
|
| 113 |
setRunning(false);
|
| 114 |
try {
|
| 115 |
+
const res = await fetch(api('/api/score'), {
|
| 116 |
method: 'POST',
|
| 117 |
headers: { 'Content-Type': 'application/json' },
|
| 118 |
body: JSON.stringify({ username, score: finalScore }),
|
src/Leaderboard.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
|
| 3 |
export default function Leaderboard({ username, lastResult, onPlayAgain, onChangeUser }) {
|
| 4 |
const [data, setData] = React.useState(null);
|
|
@@ -9,7 +10,7 @@ export default function Leaderboard({ username, lastResult, onPlayAgain, onChang
|
|
| 9 |
let alive = true;
|
| 10 |
(async () => {
|
| 11 |
try {
|
| 12 |
-
const r = await fetch(`/api/leaderboard?limit=25&username=${encodeURIComponent(username)}`)
|
| 13 |
.then((r) => r.json());
|
| 14 |
if (!alive) return;
|
| 15 |
if (!r.ok) throw new Error(r.error || 'failed');
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { api } from './api.js';
|
| 3 |
|
| 4 |
export default function Leaderboard({ username, lastResult, onPlayAgain, onChangeUser }) {
|
| 5 |
const [data, setData] = React.useState(null);
|
|
|
|
| 10 |
let alive = true;
|
| 11 |
(async () => {
|
| 12 |
try {
|
| 13 |
+
const r = await fetch(api(`/api/leaderboard?limit=25&username=${encodeURIComponent(username)}`))
|
| 14 |
.then((r) => r.json());
|
| 15 |
if (!alive) return;
|
| 16 |
if (!r.ok) throw new Error(r.error || 'failed');
|
src/MobileGame.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
|
| 3 |
const GAME_W = 640;
|
| 4 |
const GAME_H = 380;
|
|
@@ -45,7 +46,7 @@ export default function MobileGame({ username, onGameOver }) {
|
|
| 45 |
stateRef.current.running = false;
|
| 46 |
setRunning(false);
|
| 47 |
try {
|
| 48 |
-
const res = await fetch('/api/score', {
|
| 49 |
method: 'POST',
|
| 50 |
headers: { 'Content-Type': 'application/json' },
|
| 51 |
body: JSON.stringify({ username, score: finalScore }),
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { api } from './api.js';
|
| 3 |
|
| 4 |
const GAME_W = 640;
|
| 5 |
const GAME_H = 380;
|
|
|
|
| 46 |
stateRef.current.running = false;
|
| 47 |
setRunning(false);
|
| 48 |
try {
|
| 49 |
+
const res = await fetch(api('/api/score'), {
|
| 50 |
method: 'POST',
|
| 51 |
headers: { 'Content-Type': 'application/json' },
|
| 52 |
body: JSON.stringify({ username, score: finalScore }),
|
src/UsernameGate.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
|
| 3 |
export default function UsernameGate({ onPick, isMobile }) {
|
| 4 |
const [value, setValue] = React.useState('');
|
|
@@ -21,7 +22,7 @@ export default function UsernameGate({ onPick, isMobile }) {
|
|
| 21 |
setBusy(true);
|
| 22 |
setError('');
|
| 23 |
try {
|
| 24 |
-
const r = await fetch('/api/user/check', {
|
| 25 |
method: 'POST',
|
| 26 |
headers: { 'Content-Type': 'application/json' },
|
| 27 |
body: JSON.stringify({ username: name }),
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { api } from './api.js';
|
| 3 |
|
| 4 |
export default function UsernameGate({ onPick, isMobile }) {
|
| 5 |
const [value, setValue] = React.useState('');
|
|
|
|
| 22 |
setBusy(true);
|
| 23 |
setError('');
|
| 24 |
try {
|
| 25 |
+
const r = await fetch(api('/api/user/check'), {
|
| 26 |
method: 'POST',
|
| 27 |
headers: { 'Content-Type': 'application/json' },
|
| 28 |
body: JSON.stringify({ username: name }),
|
src/api.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Resolve API base URL.
|
| 2 |
+
// - Dev: empty string → relative /api/* hits Vite's proxy to localhost:5174
|
| 3 |
+
// - All-in-one prod: empty string → relative /api/* hits the same Express server that serves dist/
|
| 4 |
+
// - Split prod (frontend on Vercel, backend on HF Space): set VITE_API_BASE=https://<space>.hf.space at build time
|
| 5 |
+
const RAW = import.meta.env.VITE_API_BASE || '';
|
| 6 |
+
export const API_BASE = RAW.replace(/\/+$/, '');
|
| 7 |
+
|
| 8 |
+
export function api(path) {
|
| 9 |
+
return API_BASE + path;
|
| 10 |
+
}
|
vercel.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://openapi.vercel.sh/vercel.json",
|
| 3 |
+
"buildCommand": "npm run build",
|
| 4 |
+
"outputDirectory": "dist",
|
| 5 |
+
"framework": "vite",
|
| 6 |
+
"rewrites": [
|
| 7 |
+
{ "source": "/controller", "destination": "/index.html" }
|
| 8 |
+
]
|
| 9 |
+
}
|