A TypeScript quiz game engine with Liveblocks real-time sync, React hooks, configurable scoring, and power-up support
npm install amalie-engineA TypeScript quiz game engine with Liveblocks real-time sync, React hooks for Next.js apps, configurable scoring systems, and power-up support.
- 🎮 Complete Game Engine - State machine handling lobby, playing, revealing, and finished phases
- ⚡ Liveblocks Realtime - Built-in real-time multiplayer with automatic reconnection
- 🎯 Multiple Question Types - Multiple choice, text input, and numeric/estimation questions
- 🏆 Flexible Scoring - Time bonuses, streak multipliers, difficulty modifiers, and golf-style estimation scoring
- 💪 Power-ups - Built-in power-ups like double points, 50/50, extra time, and shields
- 🔌 React Hooks - useQuizHost and useQuizPlayer hooks for easy integration
- 📱 QR Code Component - Easy player joining with QR codes
- 🔄 Reconnection Support - Player identity persistence and automatic reconnection
- 📦 Lightweight - Minimal dependencies, tree-shakeable exports
- 🤖 AI-Ready - Includes AI_INSTRUCTIONS.md for AI-assisted development
``bash`
npm install amalie-engine @liveblocks/client @liveblocks/reactor
yarn add amalie-engine @liveblocks/client @liveblocks/reactor
pnpm add amalie-engine @liveblocks/client @liveblocks/react
For QR code support:
`bash`
npm install qrcode.react
1. Sign up at liveblocks.io
2. Create a project and get your public API key (starts with pk_).env.local
3. Add it to your :
``
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_...
`tsx
'use client'
import { useState, useMemo } from 'react'
import { QuizProvider, useQuizHost, generateRoomCode } from 'amalie-engine'
import type { Question, Presence, QuizGameConfig } from 'amalie-engine'
const LIVEBLOCKS_KEY = process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!
const questions: Question[] = [
{
id: '1',
category: 'Science',
text: 'What is the chemical symbol for gold?',
answerType: 'multiple-choice',
options: ['Ag', 'Au', 'Fe', 'Cu'],
correctOptionIndex: 1,
},
// ... more questions
]
const config: QuizGameConfig = {
scoring: {
basePoints: 100,
timeBonus: { enabled: true, maxBonus: 50, decayPerSecond: 5 },
},
questionTimeLimit: 20,
}
function HostGame() {
const {
gameState,
currentQuestion,
players,
scoreboard,
roomCode,
startGame,
nextQuestion,
revealAnswer,
isConnected,
} = useQuizHost({ config, questions })
if (gameState === 'lobby') {
return (
// ... other game states
}
export default function HostPage() {
const roomCode = useMemo(() => generateRoomCode(), [])
const initialPresence: Presence = {
playerId: 'host',
playerName: 'Host',
isHost: true,
joinedAt: Date.now(),
}
return (
roomCode={roomCode}
initialPresence={initialPresence}
>
)
}
`
`tsx
'use client'
import { useState, useMemo } from 'react'
import { QuizProvider, useQuizPlayer, generatePlayerId } from 'amalie-engine'
import type { Presence } from 'amalie-engine'
const LIVEBLOCKS_KEY = process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!
function PlayerGame({ playerName }: { playerName: string }) {
const { gameState, currentQuestion, myScore, hasAnswered, submitAnswer, isConnected } =
useQuizPlayer({ playerName })
if (gameState === 'lobby') {
return
if (gameState === 'playing' && currentQuestion) {
return (
{currentQuestion.text}
return
export default function PlayerPage({ params }: { params: { code: string } }) {
const [playerName, setPlayerName] = useState('')
const [joined, setJoined] = useState(false)
const playerId = useMemo(() => generatePlayerId(), [])
if (!joined) {
return (
const initialPresence: Presence = {
playerId,
playerName,
isHost: false,
joinedAt: Date.now(),
}
return (
roomCode={params.code}
initialPresence={initialPresence}
>
)
}
`
`typescript`
const question: Question = {
id: 'mc-1',
category: 'Geography',
text: 'What is the capital of France?',
answerType: 'multiple-choice',
options: ['London', 'Paris', 'Berlin', 'Madrid'],
correctOptionIndex: 1,
difficulty: 'easy',
}
`typescript`
const question: Question = {
id: 'text-1',
category: 'Geography',
text: 'Name the largest country by area',
answerType: 'text',
correctText: 'Russia',
acceptedAnswers: ['Russia', 'Russian Federation'],
caseSensitive: false,
}
`typescript`
const question: Question = {
id: 'est-1',
category: 'History',
text: 'In what year did World War II end?',
answerType: 'numeric',
correctNumber: 1945,
lowerBound: 1900,
upperBound: 2000,
}
`typescript`
const config: QuizGameConfig = {
scoring: {
basePoints: 100,
timeBonus: {
enabled: true,
maxBonus: 50,
decayPerSecond: 5,
},
streakBonus: {
enabled: true,
multiplierPerStreak: 0.1,
maxMultiplier: 2,
},
difficultyMultipliers: {
easy: 1,
medium: 1.5,
hard: 2,
},
},
}
`typescript`
const config: QuizGameConfig = {
questionsPerGame: 10,
questionTimeLimit: 20,
autoAdvanceOnAllAnswered: true, // Auto-reveal when all answered
}
`tsx`
const {
allPlayersAnswered, // true when all connected players have answered
endRound, // Force end current round
revealAnswer, // Reveal the answer
} = useQuizHost(options)
`typescript
import {
DOUBLE_POINTS,
FIFTY_FIFTY,
EXTRA_TIME,
SKIP_QUESTION,
SHIELD,
STEAL_POINTS,
} from 'amalie-engine'
const config: QuizGameConfig = {
powerups: [DOUBLE_POINTS, FIFTY_FIFTY, SHIELD],
}
`
Wraps your quiz components and establishes the Liveblocks connection.
`tsx`
roomCode="ABC123" // The quiz room code
initialPresence={{
// User's presence data
playerId: 'player-1',
playerName: 'Alice',
isHost: false,
joinedAt: Date.now(),
}}
>
{children}
Host-side hook for managing a quiz game.
Options:
- config - Game configurationquestions
- - Array of questions or QuestionProviderbaseUrl
- - Base URL for join links (optional)onGameEnd
- - Callback when game endsonRematch
- - Callback before rematch
Returns:
- gameState - Current game phase ('lobby' | 'playing' | 'revealing' | 'finished')currentQuestion
- - Current question stateplayers
- - List of playersscoreboard
- - Current scoreboardanswers
- - Current round's answersallPlayersAnswered
- - Whether all connected players have answeredroomCode
- - Room codestartGame()
- - Start the gamenextQuestion()
- - Show next questionrevealAnswer()
- - Reveal the answerendRound()
- - Force end current roundendGame()
- - End the gamerematch()
- - Start a rematchkickPlayer(id)
- - Remove a playerisConnected
- - Connection statuserror
- - Connection error if any
Player-side hook for playing in a quiz game.
Options:
- playerName - Player's display name
Returns:
- gameState - Current game phasecurrentQuestion
- - Current question (without answers)myScore
- - Player's current scoremyRank
- - Player's current rankhasAnswered
- - Whether player has answeredanswerRejected
- - Whether answer was rejectedquestionTimeRemaining
- - Time remaining in mssubmitAnswer(answer)
- - Submit an answeractivatePowerup(id)
- - Use a power-upisConnected
- - Connection statusisReconnecting
- - Whether reconnectingconnectionError
- - Connection error if anyreconnect()
- - Manual reconnect
#### RoomQRCode
QR code component for player joining.
`tsx`
`typescript
import { createArrayProvider } from 'amalie-engine'
const provider = createArrayProvider(questions)
const filtered = await provider.getQuestions({
categories: ['Science'],
count: 10,
shuffle: true,
})
`
`typescript
import { createJsonUrlProvider } from 'amalie-engine'
const provider = createJsonUrlProvider('https://api.example.com/questions.json')
`
`typescript
import { createSupabaseProvider } from 'amalie-engine'
const provider = createSupabaseProvider(supabaseClient, {
tableName: 'quiz_questions',
})
`
If you're migrating from the Supabase-based version:
1. Dependencies: Replace @supabase/supabase-js with @liveblocks/client and @liveblocks/react
2. Environment: Replace NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY with NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY
3. Provider: The QuizProvider now takes different props:
`tsx
// Before (v0.1.x)
// After (v0.2.x)
roomCode={roomCode}
initialPresence={presence}
>
{children}
`
4. Hooks: Remove supabaseClient from hook options:
`tsx
// Before
useQuizHost({ supabaseClient, config, questions })
useQuizPlayer({ supabaseClient, roomCode, playerName })
// After
useQuizHost({ config, questions })
useQuizPlayer({ playerName })
`
5. Room code: Now passed to QuizProvider` instead of hooks
MIT