amalie-engine
v0.3.1
Published
A TypeScript quiz game engine with Liveblocks real-time sync, React hooks, configurable scoring, and power-up support
Maintainers
Readme
Amalie Engine
A TypeScript quiz game engine with Liveblocks real-time sync, React hooks for Next.js apps, configurable scoring systems, and power-up support.
Features
- 🎮 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 -
useQuizHostanduseQuizPlayerhooks 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
Installation
npm install amalie-engine @liveblocks/client @liveblocks/react
# or
yarn add amalie-engine @liveblocks/client @liveblocks/react
# or
pnpm add amalie-engine @liveblocks/client @liveblocks/reactOptional Dependencies
For QR code support:
npm install qrcode.reactQuick Start
1. Get a Liveblocks API Key
- Sign up at liveblocks.io
- Create a project and get your public API key (starts with
pk_) - Add it to your
.env.local:
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_...2. Create the Host Screen
'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 (
<div>
<h1>Room: {roomCode}</h1>
<h2>Players ({players.length})</h2>
<ul>
{players.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<button onClick={startGame} disabled={players.length === 0}>
Start Game
</button>
</div>
)
}
// ... other game states
}
export default function HostPage() {
const roomCode = useMemo(() => generateRoomCode(), [])
const initialPresence: Presence = {
playerId: 'host',
playerName: 'Host',
isHost: true,
joinedAt: Date.now(),
}
return (
<QuizProvider
publicApiKey={LIVEBLOCKS_KEY}
roomCode={roomCode}
initialPresence={initialPresence}
>
<HostGame />
</QuizProvider>
)
}3. Create the Player Screen
'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 <div>Waiting for host to start...</div>
}
if (gameState === 'playing' && currentQuestion) {
return (
<div>
<p>{currentQuestion.text}</p>
{currentQuestion.options?.map((opt, i) => (
<button key={i} onClick={() => submitAnswer(i)} disabled={hasAnswered}>
{opt}
</button>
))}
</div>
)
}
return <div>Score: {myScore}</div>
}
export default function PlayerPage({ params }: { params: { code: string } }) {
const [playerName, setPlayerName] = useState('')
const [joined, setJoined] = useState(false)
const playerId = useMemo(() => generatePlayerId(), [])
if (!joined) {
return (
<div>
<input
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Your name"
/>
<button onClick={() => setJoined(true)} disabled={!playerName}>
Join
</button>
</div>
)
}
const initialPresence: Presence = {
playerId,
playerName,
isHost: false,
joinedAt: Date.now(),
}
return (
<QuizProvider
publicApiKey={LIVEBLOCKS_KEY}
roomCode={params.code}
initialPresence={initialPresence}
>
<PlayerGame playerName={playerName} />
</QuizProvider>
)
}Question Types
Multiple Choice
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',
}Text Input
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,
}Numeric/Estimation
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,
}Scoring Configuration
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,
},
},
}Round Behavior
const config: QuizGameConfig = {
questionsPerGame: 10,
questionTimeLimit: 20,
autoAdvanceOnAllAnswered: true, // Auto-reveal when all answered
}Host Controls
const {
allPlayersAnswered, // true when all connected players have answered
endRound, // Force end current round
revealAnswer, // Reveal the answer
} = useQuizHost(options)Power-ups
import {
DOUBLE_POINTS,
FIFTY_FIFTY,
EXTRA_TIME,
SKIP_QUESTION,
SHIELD,
STEAL_POINTS,
} from 'amalie-engine'
const config: QuizGameConfig = {
powerups: [DOUBLE_POINTS, FIFTY_FIFTY, SHIELD],
}API Reference
QuizProvider
Wraps your quiz components and establishes the Liveblocks connection.
<QuizProvider
publicApiKey="pk_..." // Your Liveblocks public key
roomCode="ABC123" // The quiz room code
initialPresence={{
// User's presence data
playerId: 'player-1',
playerName: 'Alice',
isHost: false,
joinedAt: Date.now(),
}}
>
{children}
</QuizProvider>useQuizHost(options)
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
useQuizPlayer(options)
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
Components
RoomQRCode
QR code component for player joining.
<RoomQRCode roomCode="ABC123" baseUrl="https://myquiz.com/play" size={256} />Question Providers
From Array
import { createArrayProvider } from 'amalie-engine'
const provider = createArrayProvider(questions)
const filtered = await provider.getQuestions({
categories: ['Science'],
count: 10,
shuffle: true,
})From JSON URL
import { createJsonUrlProvider } from 'amalie-engine'
const provider = createJsonUrlProvider('https://api.example.com/questions.json')From Supabase (for question storage only)
import { createSupabaseProvider } from 'amalie-engine'
const provider = createSupabaseProvider(supabaseClient, {
tableName: 'quiz_questions',
})Migration from v0.1.x (Supabase Realtime)
If you're migrating from the Supabase-based version:
Dependencies: Replace
@supabase/supabase-jswith@liveblocks/clientand@liveblocks/reactEnvironment: Replace
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEYwithNEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEYProvider: The
QuizProvidernow takes different props:// Before (v0.1.x) <QuizProvider supabaseClient={supabase}>{children}</QuizProvider> // After (v0.2.x) <QuizProvider publicApiKey={liveblocksKey} roomCode={roomCode} initialPresence={presence} > {children} </QuizProvider>Hooks: Remove
supabaseClientfrom hook options:// Before useQuizHost({ supabaseClient, config, questions }) useQuizPlayer({ supabaseClient, roomCode, playerName }) // After useQuizHost({ config, questions }) useQuizPlayer({ playerName })Room code: Now passed to
QuizProviderinstead of hooks
License
MIT
