A real-time 2D multiplayer duel game built with Next.js (App Router), TypeScript, Tailwind CSS, MongoDB (Mongoose), Server-Sent Events (SSE) + client-side interpolation. Two players (AI agents or humans) join, duel with bullets and jumps, and the winner is declared when an opponent loses all lives.
- Multiplayer Support: Exactly 2 players can join each game
- Real-time Gameplay: Game updates in real-time with smooth animations
- Simple Controls: Players can shoot and jump
- Lives System: Each player starts with 3 lives
- Auto-start: Game automatically starts after 3-second countdown when both players join
- API Endpoints: Simple REST API for controlling players programmatically
- Responsive Design: Works on both desktop and mobile devices
- Shooting: Players can shoot bullets at each other with a cooldown period
- Jumping: Players can jump to avoid incoming bullets
- Collision Detection: Bullets hitting players reduce their lives
- Win Condition: Game ends when one player loses all 3 lives
All endpoints support CORS and can be accessed after deployment:
POST /api/game/join
Content-Type: application/json
{
"playerName": "YourName"
}
Response:
{
"success": true,
"playerId": "unique-player-id"
}POST /api/game/shoot
Content-Type: application/json
{
"playerId": "your-player-id"
}POST /api/game/jump
Content-Type: application/json
{
"playerId": "your-player-id"
}GET /api/game/state
Response:
{
"success": true,
"gameState": {
"players": [...],
"bullets": [...],
"gameStatus": "waiting|countdown|playing|finished",
"winner": "winner-name"
}
}POST /api/game/reset-
Install Dependencies:
npm install
-
Run Development Server:
npm run dev
-
Open Browser: Navigate to
http://localhost:3000
-
Install Vercel CLI:
npm install -g vercel
-
Login to Vercel:
vercel login
-
Deploy:
vercel
-
Follow the prompts:
- Set up and deploy? Yes
- Which scope? (Select your account)
- Link to existing project? No
- What's your project's name? (e.g., duel-game)
- In which directory is your code located? ./
-
Push to GitHub:
git init git add . git commit -m "Initial commit" git branch -M main git remote add origin https://github.com/yourusername/duel-game.git git push -u origin main
-
Connect to Vercel:
- Go to vercel.com
- Click "New Project"
- Import your GitHub repository
- Click "Deploy"
You now NEED MongoDB (Atlas or local). Create a .env.local file:
MONGODB_URI="your-mongodb-connection-string"
MONGODB_DB=duel_game
If you want to experiment purely in-memory (NOT recommended for Vercel) you could revert to the legacy gameState.ts, but expect flickering on serverless cold starts as described below.
duel-game/
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── game/
│ │ │ ├── join/route.ts
│ │ │ ├── shoot/route.ts
│ │ │ ├── jump/route.ts
│ │ │ ├── state/route.ts
│ │ │ ├── reset/route.ts
│ │ │ └── stream/route.ts # SSE streaming endpoint (Node runtime)
│ │ ├── layout.tsx
│ │ └── page.tsx # Uses SSE + interpolation + fallback polling
│ ├── components/
│ │ ├── GameCanvas.tsx
│ │ └── GameControls.tsx
│ ├── lib/
│ │ ├── dbGameState.ts # Game logic w/ optimistic concurrency + simulation
│ │ ├── dbConnect.ts # Mongoose singleton connector
│ │ └── gameState.ts # (Legacy) in-memory implementation (unused in prod)
│ ├── models/
│ │ └── GameState.ts # Mongoose schema/model
│ └── types/
│ └── game.ts
├── public/
└── README.md
- Join the Game: Enter your name and click "Join Game"
- Wait for Second Player: The game requires exactly 2 players
- Countdown: Once both players join, a 3-second countdown begins
- Play: Use the SHOOT and JUMP buttons to battle your opponent
- Win: Reduce your opponent's lives to 0 to win!
- Framework: Next.js 15 (App Router)
- Language: TypeScript
- Styling: Tailwind CSS
- Persistence: MongoDB (Mongoose) single-document authoritative state
- Concurrency Control: Optimistic updates with version field (atomic updateOne w/ match on version)
- Simulation: Server-side discrete time advancement upon each read/mutation; bullets use swept collision to prevent tunneling
- Realtime Delivery: SSE @ ~4Hz + client interpolation (60 FPS animation) + adaptive fallback polling
- Client Interpolation: Moves bullets, updates jump arc, smooths countdown locally between server ticks
- Reset Logic: Monotonic version increment (never resets to 0) to avoid stale overwrites
- API Stability: All endpoints idempotent or version-safe; joins guard against race conditions
SSE is simpler on Vercel (no custom server) and cheap for one-to-few spectators. WebSockets could further reduce JSON overhead but require additional infra or Vercel Edge functions constraints.
SSE route uses runtime = "nodejs" because Mongoose requires Node APIs not reliably available in Edge runtime.
| Phase | Approach | Issue Encountered | Resolution |
|---|---|---|---|
| 1 | In-memory state in serverless routes | Flickering / resets (each cold instance had its own copy) | Introduced versioning concept (still fragile) |
| 2 | Added manual version guarding | Still lost state on cold-start or parallel instance writes | Migrated to MongoDB singleton document |
| 3 | Naive rapid polling (50ms) on clients | Hit MongoDB Atlas read ops limits quickly | Introduced adaptive polling + optimistic UI |
| 4 | Mongo read on every 50ms tick (multiple clients) | Scaling concern & cost | Switched to SSE stream + per-tick server fetch only |
| 5 | Occasional race on simultaneous joins | Player name overwritten | Added optimistic concurrency mutateState loop |
| 6 | Countdown sometimes froze after joins | Stale overwrite by slow poll | All state changes consolidated through mutateState |
| 7 | Deployment error using Edge SSE with Mongoose | Edge runtime incompatibility | Moved SSE to Node runtime |
- Monotonic Version: Clients ignore any snapshot with a lower version to eliminate rollback flicker.
- Optimistic Concurrency: updateOne w/
{ _id, version }filter; retry loop caps at few attempts. - Client Interpolation: Allows reducing authoritative fetch rate 5–10× while preserving smoothness.
- SSE Streaming: Replaces many client polls with a single push channel (auto-reconnect after 55s).
Initial design polled every 50ms (~20 req/sec per viewer). Two viewers + AI agents easily exceeded free-tier read limits. Final design:
- SSE push: 4 updates/sec (configurable) per connected client.
- Fallback adaptive polling only if SSE fails: 0.8–5 req/sec depending on state phase.
- Each reset / action causes a single targeted write; no redundant full-document rewrites beyond necessary fields.
If you need even fewer reads:
- Lower SSE frequency when
gameStatus !== 'playing'. - Send diffs instead of full snapshots (not yet implemented—straightforward future enhancement).
- Implement server-driven backoff when no active players.
- Cache last serialized payload in-memory and only re-query DB if enough ms elapsed or a mutation occurred.
Start a local MongoDB (e.g. brew services start mongodb-community) then run:
npm install
npm run dev
If MONGODB_URI is absent the connector defaults to mongodb://localhost:27017/duel_game.
- Provide
MONGODB_URI&MONGODB_DBin Vercel Project Environment Variables (Production + Preview). - Confirm SSE route (
/api/game/stream) uses Node runtime (DO NOT setedge). - Ensure cold starts won't cause UI rollback: keep monotonic version increments (never reset to 0 in production resets).
- Monitor MongoDB metrics; if read ops creep up, dial down
TICK_MSin the stream route or add idle throttling. - Consider a periodic auto-reset if state remains
finishedorwaitingfor long durations to keep gameplay fresh.
An operational health endpoint is available at:
GET /api/health
Response example:
{
"status": "up",
"db": "ok",
"stateVersion": 42,
"uptimeMs": 123456,
"latencyMs": 18
}Use this endpoint to warm the Mongo connection after deployment (first invocation triggers connect) and for uptime monitoring. A 503 status indicates the database could not be reached after retry attempts.
Potential improvements for advanced users:
- WebSocket transport (binary diff frames)
- Sharded per-room architecture (multiple concurrent matches)
- Replay recording (append-only event log with deterministic re-sim)
- Bot framework (pluggable AI strategies via serverless invocations)
- Observability: expose metrics endpoint with counts (joins, shots, hits, latency)
| Symptom | Likely Cause | Fix |
|---|---|---|
| Flickering or state rollback | Receiving older version snapshot | Ensure client ignores lower version (already implemented) |
| Countdown freezes | Stale poll overwrote updated state | All state must go through mutateState (already fixed) |
| Second player disappears | Race on join | Optimistic concurrency join logic (already fixed) |
| High Mongo read ops | Excessive polling | Rely on SSE + interpolation; adjust tick rate |
| Build fails on stream route | Edge runtime with Mongoose | Set runtime = 'nodejs' in stream route |
| Bullets pass through player | Tunneling at high speed | Swept AABB collision implemented |
# Join game
curl -X POST https://your-app.vercel.app/api/game/join \
-H "Content-Type: application/json" \
-d '{"playerName": "Player1"}'
# Shoot
curl -X POST https://your-app.vercel.app/api/game/shoot \
-H "Content-Type: application/json" \
-d '{"playerId": "your-player-id"}'
# Jump
curl -X POST https://your-app.vercel.app/api/game/jump \
-H "Content-Type: application/json" \
-d '{"playerId": "your-player-id"}'// Join game
const response = await fetch("/api/game/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerName: "Player1" }),
});
const { playerId } = await response.json();
// Shoot
await fetch("/api/game/shoot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId }),
});MIT License - feel free to use / adapt for learning, experiments, or as a starter template.
- Fork the repository
- Create a feature branch
- Make your changes
- Submit a pull request
If you encounter any issues or have questions, please open an issue on GitHub.