Skip to content

Instantly share code, notes, and snippets.

@Oluwasetemi
Created November 12, 2025 22:37
Show Gist options
  • Select an option

  • Save Oluwasetemi/13f9da827ade3d4632086634bc3a1a1b to your computer and use it in GitHub Desktop.

Select an option

Save Oluwasetemi/13f9da827ade3d4632086634bc3a1a1b to your computer and use it in GitHub Desktop.
something to reproduce the deploy bug

PartyKit Servers

This directory contains PartyKit server implementations for real-time multiplayer features.

Available Servers

1. Polls Server (polls.ts)

Real-time polling system where users can create polls, vote, and see results update live.

Features:

  • Create polls with multiple options
  • Real-time vote updates
  • One vote per user
  • Poll creator can end the poll
  • Connection count tracking

Client Usage:

import { PollClient } from '~/components/PollClient'

<PollClient
  roomId="my-poll-room"
  host="localhost:1999"
/>

Demo: /demo/party/polls

2. Kahoot Server (kahoot.ts)

Kahoot-style quiz game with real-time gameplay, scoring, and leaderboards.

Features:

  • Host creates games with multiple questions
  • Players join with their names
  • Real-time question delivery
  • Time-based scoring (faster = more points)
  • Live leaderboards
  • Final rankings

Host Usage:

import { KahootHost } from '~/components/KahootHost'

<KahootHost
  roomId="game-xyz"
  host="localhost:1999"
/>

Player Usage:

import { KahootPlayer } from '~/components/KahootPlayer'

<KahootPlayer
  roomId="game-xyz"
  playerName="John"
  host="localhost:1999"
/>

Demo:

  • Host: /demo/party/kahoot-host
  • Player: /demo/party/kahoot-player?room=<room-id>

Development

Setup

  1. Install dependencies (already done):
bun install
  1. Configure environment variables:
cp .env.example .env.local

Running PartyKit Server

Start the PartyKit development server:

bunx partykit dev

This will start the PartyKit server on localhost:1999.

Running the Application

In another terminal, start the Vite development server:

bun run dev

This will start the application on localhost:3000.

Testing

  1. Open the polls demo at http://localhost:3000/demo/party/polls
  2. Open the same URL in multiple browser windows to see real-time updates
  3. Create a poll in one window and vote in others

For Kahoot:

  1. Open the host dashboard at http://localhost:3000/demo/party/kahoot-host
  2. Copy the player link and open it in other windows/devices
  3. Create questions, wait for players, and start the game

Deployment

Deploy to PartyKit

  1. Login to PartyKit:
bunx partykit login
  1. Deploy your servers:
bunx partykit deploy
  1. Update your .env file with the production PartyKit URL:
VITE_PARTYKIT_HOST=your-project.partykit.dev

Architecture

Polls Server

Client Message Types:
- create_poll: Create a new poll
- vote: Submit a vote
- end_poll: End the current poll
- get_results: Request current results

Server Message Types:
- poll_created: Poll was created
- poll_updated: Poll state changed
- poll_ended: Poll was ended
- error: Error occurred
- connection_count: Number of connected users

Kahoot Server

Client Message Types (Host):
- host_create: Create a game
- host_start: Start the game
- host_next_question: Move to next question
- host_end_game: End the game

Client Message Types (Player):
- player_join: Join the game
- player_answer: Submit an answer

Server Message Types:
- game_created: Game was created
- player_joined: New player joined
- game_started: Game started
- question_started: New question
- question_ended: Question ended with results
- game_ended: Game ended with final rankings
- player_answered: A player submitted an answer
- game_state: Current game state
- error: Error occurred

Storage

Both servers use PartyKit's built-in storage:

  • Polls: Stores poll data and voter list
  • Kahoot: Stores game state, questions, and player data

Storage persists across server restarts within the same room.

Configuration

The partykit.json file configures the available parties:

{
  "parties": {
    "polls": "party/polls.ts",
    "kahoot": "party/kahoot.ts"
  }
}

Each party is accessible via its name in the PartySocket connection.

import type * as Party from 'partykit/server'
import { timestamp } from '@setemiojo/utils'
type FeedbackType = 'emoji' | 'text' | 'score'
interface EmojiOption {
emoji: string
label: string
count: number
}
interface TextResponse {
id: string
text: string
timestamp: number
}
interface ScoreData {
total: number
count: number
average: number
distribution: { [key: number]: number } // score -> count
}
interface FeedbackSession {
id: string
title: string
type: FeedbackType
createdBy: string
createdAt: number
isActive: boolean
// Type-specific data
emojiOptions?: EmojiOption[]
textResponses?: TextResponse[]
scoreData?: ScoreData
scoreRange?: { min: number, max: number }
}
interface Responder {
id: string
hasResponded: boolean
}
type ClientMessage
= | { type: 'create_feedback', title: string, feedbackType: FeedbackType, config?: any }
| { type: 'submit_emoji', emoji: string }
| { type: 'submit_text', text: string }
| { type: 'submit_score', score: number }
| { type: 'close_feedback' }
| { type: 'get_state' }
type ServerMessage
= | { type: 'feedback_created', session: FeedbackSession }
| { type: 'feedback_updated', session: FeedbackSession }
| { type: 'feedback_closed', session: FeedbackSession }
| { type: 'error', message: string }
| { type: 'connection_count', count: number }
| { type: 'response_submitted' }
export default class FeedbackServer implements Party.Server {
private session: FeedbackSession | null = null
private responders: Map<string, Responder> = new Map()
private hostId: string | null = null
constructor(readonly room: Party.Room) {}
async onStart() {
// TODO: performance
const storedSession = await this.room.storage.get<FeedbackSession>('session')
if (storedSession) {
this.session = storedSession
}
const storedResponders = await this.room.storage.get<Array<[string, Responder]>>('responders')
if (storedResponders) {
this.responders = new Map(storedResponders)
}
const storedHostId = await this.room.storage.get<string>('hostId')
if (storedHostId) {
this.hostId = storedHostId
}
}
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
console.warn(
`Connected to feedback room:
id: ${conn.id}
room: ${this.room.id}
url: ${new URL(ctx.request.url).pathname}`,
)
if (this.session) {
conn.send(
JSON.stringify({
type: 'feedback_updated',
session: this.session,
} as ServerMessage),
)
}
if (!this.responders.has(conn.id)) {
this.responders.set(conn.id, { id: conn.id, hasResponded: false })
}
this.broadcastConnectionCount()
}
onClose(_conn: Party.Connection) {
this.broadcastConnectionCount()
}
async onMessage(message: string | ArrayBuffer, sender: Party.Connection) {
if (typeof message !== 'string')
return
try {
const data: ClientMessage = JSON.parse(message)
switch (data.type) {
case 'create_feedback':
await this.handleCreateFeedback(data, sender)
break
case 'submit_emoji':
await this.handleSubmitEmoji(data, sender)
break
case 'submit_text':
await this.handleSubmitText(data, sender)
break
case 'submit_score':
await this.handleSubmitScore(data, sender)
break
case 'close_feedback':
await this.handleCloseFeedback(sender)
break
case 'get_state':
this.sendSessionState(sender)
break
}
}
catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Invalid message format'
sender.send(
JSON.stringify({
type: 'error',
message: errorMessage,
} as ServerMessage),
)
}
}
private async handleCreateFeedback(
data: { title: string, feedbackType: FeedbackType, config?: any },
sender: Party.Connection,
) {
if (this.session && this.session.isActive) {
sender.send(
JSON.stringify({
type: 'error',
message: 'A feedback session is already active',
} as ServerMessage),
)
return
}
this.hostId = sender.id
// Create new session based on type
this.session = {
id: crypto.randomUUID(),
title: data.title,
type: data.feedbackType,
createdBy: sender.id,
createdAt: timestamp(),
isActive: true,
}
// Initialize type-specific data
switch (data.feedbackType) {
case 'emoji':
this.session.emojiOptions = data.config?.emojis || [
{ emoji: '😍', label: 'Love it', count: 0 },
{ emoji: '😊', label: 'Good', count: 0 },
{ emoji: '😐', label: 'Okay', count: 0 },
{ emoji: '😞', label: 'Not good', count: 0 },
]
break
case 'text':
this.session.textResponses = []
break
case 'score':
this.session.scoreRange = data.config?.range || { min: 1, max: 10 }
this.session.scoreData = {
total: 0,
count: 0,
average: 0,
distribution: {},
}
break
}
// Reset responders
this.responders.clear()
for (const conn of this.room.getConnections()) {
this.responders.set(conn.id, { id: conn.id, hasResponded: false })
}
await this.saveSessionState()
await this.room.storage.put('hostId', this.hostId)
// Broadcast to all connections
this.room.broadcast(
JSON.stringify({
type: 'feedback_created',
session: this.session,
} as ServerMessage),
)
}
private async handleSubmitEmoji(
data: { emoji: string },
sender: Party.Connection,
) {
if (!this.session || this.session.type !== 'emoji') {
sender.send(
JSON.stringify({
type: 'error',
message: 'No active emoji feedback session',
} as ServerMessage),
)
return
}
if (!this.session.isActive) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Feedback session is closed',
} as ServerMessage),
)
return
}
const responder = this.responders.get(sender.id)
if (responder?.hasResponded) {
sender.send(
JSON.stringify({
type: 'error',
message: 'You have already submitted feedback',
} as ServerMessage),
)
return
}
// Find and increment emoji count
const emojiOption = this.session.emojiOptions?.find(
opt => opt.emoji === data.emoji,
)
if (!emojiOption) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Invalid emoji option',
} as ServerMessage),
)
return
}
emojiOption.count++
// Mark responder as responded
if (responder) {
responder.hasResponded = true
}
await this.saveSessionState()
sender.send(
JSON.stringify({
type: 'response_submitted',
} as ServerMessage),
)
// Broadcast updated session
this.room.broadcast(
JSON.stringify({
type: 'feedback_updated',
session: this.session,
} as ServerMessage),
)
}
private async handleSubmitText(
data: { text: string },
sender: Party.Connection,
) {
if (!this.session || this.session.type !== 'text') {
sender.send(
JSON.stringify({
type: 'error',
message: 'No active text feedback session',
} as ServerMessage),
)
return
}
if (!this.session.isActive) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Feedback session is closed',
} as ServerMessage),
)
return
}
const responder = this.responders.get(sender.id)
if (responder?.hasResponded) {
sender.send(
JSON.stringify({
type: 'error',
message: 'You have already submitted feedback',
} as ServerMessage),
)
return
}
// Add text response
const textResponse: TextResponse = {
id: crypto.randomUUID(),
text: data.text.trim(),
timestamp: timestamp(),
}
this.session.textResponses?.push(textResponse)
// Mark responder as responded
if (responder) {
responder.hasResponded = true
}
await this.saveSessionState()
sender.send(
JSON.stringify({
type: 'response_submitted',
} as ServerMessage),
)
// Broadcast updated session
this.room.broadcast(
JSON.stringify({
type: 'feedback_updated',
session: this.session,
} as ServerMessage),
)
}
private async handleSubmitScore(
data: { score: number },
sender: Party.Connection,
) {
if (!this.session || this.session.type !== 'score') {
sender.send(
JSON.stringify({
type: 'error',
message: 'No active score feedback session',
} as ServerMessage),
)
return
}
if (!this.session.isActive) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Feedback session is closed',
} as ServerMessage),
)
return
}
const responder = this.responders.get(sender.id)
if (responder?.hasResponded) {
sender.send(
JSON.stringify({
type: 'error',
message: 'You have already submitted feedback',
} as ServerMessage),
)
return
}
// Validate score is in range
const { min, max } = this.session.scoreRange!
if (data.score < min || data.score > max) {
sender.send(
JSON.stringify({
type: 'error',
message: `Score must be between ${min} and ${max}`,
} as ServerMessage),
)
return
}
// Update score data
const scoreData = this.session.scoreData!
scoreData.total += data.score
scoreData.count++
scoreData.average = scoreData.total / scoreData.count
scoreData.distribution[data.score] = (scoreData.distribution[data.score] || 0) + 1
// Mark responder as responded
if (responder) {
responder.hasResponded = true
}
await this.saveSessionState()
sender.send(
JSON.stringify({
type: 'response_submitted',
} as ServerMessage),
)
// Broadcast updated session
this.room.broadcast(
JSON.stringify({
type: 'feedback_updated',
session: this.session,
} as ServerMessage),
)
}
private async handleCloseFeedback(sender: Party.Connection) {
if (!this.session) {
sender.send(
JSON.stringify({
type: 'error',
message: 'No active feedback session',
} as ServerMessage),
)
return
}
if (sender.id !== this.hostId) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Only the host can close the feedback session',
} as ServerMessage),
)
return
}
this.session.isActive = false
await this.saveSessionState()
this.room.broadcast(
JSON.stringify({
type: 'feedback_closed',
session: this.session,
} as ServerMessage),
)
}
private sendSessionState(conn: Party.Connection) {
if (this.session) {
conn.send(
JSON.stringify({
type: 'feedback_updated',
session: this.session,
} as ServerMessage),
)
}
}
private async saveSessionState() {
if (this.session) {
await this.room.storage.put('session', this.session)
}
await this.room.storage.put('responders', Array.from(this.responders.entries()))
}
private broadcastConnectionCount() {
const count = [...this.room.getConnections()].length
this.room.broadcast(
JSON.stringify({
type: 'connection_count',
count,
} as ServerMessage),
)
}
}
FeedbackServer satisfies Party.Worker
import type * as Party from 'partykit/server'
import { timestamp } from '@setemiojo/utils'
interface EmojiMessage {
type: 'emoji_pop'
emoji: string
userId: string
timestamp: number
x: number // X position as percentage
y: number // Y position as percentage
}
export default class FeelingsServer implements Party.Server {
constructor(readonly room: Party.Room) {}
async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
console.log(
`Connected:`,
conn.id,
'Current connections:',
[...this.room.getConnections()].length,
)
// Send current connection count to all clients
this.broadcastConnectionCount()
}
async onMessage(message: string, sender: Party.Connection) {
try {
const data = JSON.parse(message)
switch (data.type) {
case 'emoji_pop': {
// Broadcast emoji to all connected clients
const emojiMessage: EmojiMessage = {
type: 'emoji_pop',
emoji: data.emoji,
userId: sender.id,
timestamp: timestamp(),
x: data.x || Math.random() * 100,
y: data.y || Math.random() * 100,
}
this.room.broadcast(JSON.stringify(emojiMessage))
break
}
default:
console.log('Unknown message type:', data.type)
}
}
catch (error) {
console.error('Error parsing message:', error)
}
}
async onClose(connection: Party.Connection) {
console.log('Connection closed:', connection.id)
this.broadcastConnectionCount()
}
broadcastConnectionCount() {
const count = [...this.room.getConnections()].length
this.room.broadcast(
JSON.stringify({
type: 'connection_count',
count,
}),
)
}
}
FeelingsServer satisfies Party.Worker
import type * as Party from 'partykit/server'
export default class Server implements Party.Server {
constructor(readonly room: Party.Room) {}
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
// A websocket just connected!
console.log(
`Connected:
id: ${conn.id}
room: ${this.room.id}
url: ${new URL(ctx.request.url).pathname}`,
)
// let's send a message to the connection
conn.send('hello from server')
}
onMessage(message: string, sender: Party.Connection) {
// let's log the message
console.log(`connection ${sender.id} sent message: ${message}`)
// as well as broadcast it to all the other connections in the room...
this.room.broadcast(
`${sender.id}: ${message}`,
// ...except for the connection it came from
[sender.id],
)
}
}
Server satisfies Party.Worker
import type * as Party from 'partykit/server'
import { timestamp } from '@setemiojo/utils'
// Types for Kahoot game
interface Player {
id: string
name: string
score: number
answers: { questionId: string, correct: boolean, points: number }[]
}
interface Question {
id: string
question: string
options: string[]
correctAnswer: number // index of correct option
timeLimit: number // seconds
points: number
}
type GameState = 'waiting' | 'question' | 'results' | 'leaderboard' | 'ended'
interface Game {
id: string
name: string
questions: Question[]
currentQuestionIndex: number
state: GameState
players: Map<string, Player>
createdBy: string
createdAt: number
questionStartTime?: number
}
type ClientMessage
= | { type: 'host_create', name: string, questions: Omit<Question, 'id'>[] }
| { type: 'player_join', name: string }
| { type: 'host_start' }
| { type: 'host_next_question' }
| { type: 'player_answer', questionId: string, answerIndex: number }
| { type: 'host_end_game' }
| { type: 'host_restart_game' }
| { type: 'get_state' }
type ServerMessage
= | { type: 'game_created', gameId: string }
| { type: 'player_joined', player: Omit<Player, 'answers'> }
| { type: 'game_started' }
| { type: 'question_started', question: Omit<Question, 'correctAnswer'>, timeRemaining: number }
| { type: 'question_ended', correctAnswer: number, rankings: Array<{ playerId: string, name: string, score: number }> }
| { type: 'game_ended', finalRankings: Array<{ playerId: string, name: string, score: number, answers: Player['answers'] }> }
| { type: 'player_answered', playerId: string }
| { type: 'leaderboard', rankings: Array<{ playerId: string, name: string, score: number }> }
| { type: 'game_state', state: GameState, players: Array<Omit<Player, 'answers'>> }
| { type: 'error', message: string }
| { type: 'connection_count', count: number }
export default class KahootServer implements Party.Server {
private game: Game | null = null
private questionTimer: ReturnType<typeof setTimeout> | null = null
private hostId: string | null = null
private answeredPlayers: Set<string> = new Set()
constructor(readonly room: Party.Room) {}
async onStart() {
// Load game state from storage
const storedGame = await this.room.storage.get<{
id: string
name: string
questions: Question[]
currentQuestionIndex: number
state: GameState
players: Array<[string, Player]>
createdBy: string
createdAt: number
}>('game')
if (storedGame) {
this.game = {
...storedGame,
players: new Map(storedGame.players),
}
}
const storedHostId = await this.room.storage.get<string>('hostId')
if (storedHostId) {
this.hostId = storedHostId
}
}
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
console.log(
`Connected to Kahoot room:
id: ${conn.id}
room: ${this.room.id}
url: ${new URL(ctx.request.url).pathname}`,
)
// Send current game state to new connection
if (this.game) {
this.sendGameState(conn)
}
this.broadcastConnectionCount()
}
onClose(conn: Party.Connection) {
// Remove player if they disconnect
if (this.game && this.game.players.has(conn.id)) {
this.game.players.delete(conn.id)
this.saveGameState()
}
this.broadcastConnectionCount()
}
async onMessage(message: string | ArrayBuffer, sender: Party.Connection) {
if (typeof message !== 'string')
return
try {
const data: ClientMessage = JSON.parse(message)
switch (data.type) {
case 'host_create':
await this.handleHostCreate(data, sender)
break
case 'player_join':
await this.handlePlayerJoin(data, sender)
break
case 'host_start':
await this.handleHostStart(sender)
break
case 'host_next_question':
await this.handleHostNextQuestion(sender)
break
case 'player_answer':
await this.handlePlayerAnswer(data, sender)
break
case 'host_end_game':
await this.handleHostEndGame(sender)
break
case 'host_restart_game':
await this.handleRestartGame(sender)
break
case 'get_state':
this.sendGameState(sender)
break
}
}
catch (error) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Invalid message format',
} as ServerMessage),
)
}
}
private async handleHostCreate(
data: { name: string, questions: Omit<Question, 'id'>[] },
sender: Party.Connection,
) {
if (this.game) {
sender.send(
JSON.stringify({
type: 'error',
message: 'A game already exists in this room',
} as ServerMessage),
)
return
}
this.hostId = sender.id
this.game = {
id: this.room.id,
name: data.name,
questions: data.questions.map((q, i) => ({
...q,
id: `q-${i}`,
})),
currentQuestionIndex: -1,
state: 'waiting',
players: new Map(),
createdBy: sender.id,
createdAt: timestamp(),
}
await this.saveGameState()
await this.room.storage.put('hostId', this.hostId)
sender.send(
JSON.stringify({
type: 'game_created',
gameId: this.game.id,
} as ServerMessage),
)
this.broadcastGameState()
}
private async handlePlayerJoin(
data: { name: string },
sender: Party.Connection,
) {
if (!this.game) {
sender.send(
JSON.stringify({
type: 'error',
message: 'No game exists',
} as ServerMessage),
)
return
}
if (this.game.state !== 'waiting') {
sender.send(
JSON.stringify({
type: 'error',
message: 'Game has already started',
} as ServerMessage),
)
return
}
const player: Player = {
id: sender.id,
name: data.name,
score: 0,
answers: [],
}
this.game.players.set(sender.id, player)
await this.saveGameState()
this.room.broadcast(
JSON.stringify({
type: 'player_joined',
player: { id: player.id, name: player.name, score: player.score },
} as ServerMessage),
)
this.sendGameState(sender)
}
private async handleHostStart(sender: Party.Connection) {
if (!this.game || sender.id !== this.hostId) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Only the host can start the game',
} as ServerMessage),
)
return
}
if (this.game.state !== 'waiting') {
sender.send(
JSON.stringify({
type: 'error',
message: 'Game has already started',
} as ServerMessage),
)
return
}
this.game.state = 'question'
this.game.currentQuestionIndex = 0
await this.saveGameState()
this.room.broadcast(
JSON.stringify({
type: 'game_started',
} as ServerMessage),
)
// Start first question after a short delay
setTimeout(() => this.startQuestion(), 2000)
}
private async handleHostNextQuestion(sender: Party.Connection) {
if (!this.game || sender.id !== this.hostId) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Only the host can advance questions',
} as ServerMessage),
)
return
}
this.game.currentQuestionIndex++
if (this.game.currentQuestionIndex >= this.game.questions.length) {
await this.endGame()
return
}
this.game.state = 'question'
await this.saveGameState()
this.startQuestion()
}
private startQuestion() {
if (!this.game)
return
const question = this.game.questions[this.game.currentQuestionIndex]
if (!question)
return
this.game.questionStartTime = timestamp()
this.answeredPlayers.clear()
// Send question without correct answer
const { correctAnswer, ...questionWithoutAnswer } = question
this.room.broadcast(
JSON.stringify({
type: 'question_started',
question: questionWithoutAnswer,
timeRemaining: question.timeLimit,
} as ServerMessage),
)
// Set timer for question
if (this.questionTimer) {
clearTimeout(this.questionTimer)
}
this.questionTimer = setTimeout(() => {
this.endQuestion()
}, question.timeLimit * 1000)
}
private async handlePlayerAnswer(
data: { questionId: string, answerIndex: number },
sender: Party.Connection,
) {
if (!this.game)
return
const player = this.game.players.get(sender.id)
if (!player) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Player not found',
} as ServerMessage),
)
return
}
if (this.answeredPlayers.has(sender.id)) {
sender.send(
JSON.stringify({
type: 'error',
message: 'You have already answered this question',
} as ServerMessage),
)
return
}
const question = this.game.questions[this.game.currentQuestionIndex]
if (!question || question.id !== data.questionId) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Invalid question',
} as ServerMessage),
)
return
}
this.answeredPlayers.add(sender.id)
// Calculate points based on correctness and speed
const correct = data.answerIndex === question.correctAnswer
let points = 0
if (correct && this.game.questionStartTime) {
const timeElapsed = (timestamp() - this.game.questionStartTime) / 1000
const timeRemaining = Math.max(0, question.timeLimit - timeElapsed)
// Award more points for faster answers
const speedBonus = timeRemaining / question.timeLimit
points = Math.round(question.points * (0.5 + speedBonus * 0.5))
}
player.score += points
player.answers.push({
questionId: question.id,
correct,
points,
})
await this.saveGameState()
// Notify others that player answered (without revealing the answer)
this.room.broadcast(
JSON.stringify({
type: 'player_answered',
playerId: sender.id,
} as ServerMessage),
[sender.id],
)
}
private async endQuestion() {
if (!this.game)
return
const question = this.game.questions[this.game.currentQuestionIndex]
this.game.state = 'results'
const rankings = Array.from(this.game.players.values())
.sort((a, b) => b.score - a.score)
.map(p => ({ playerId: p.id, name: p.name, score: p.score }))
await this.saveGameState()
this.room.broadcast(
JSON.stringify({
type: 'question_ended',
correctAnswer: question.correctAnswer,
rankings,
} as ServerMessage),
)
}
private async endGame() {
if (!this.game)
return
this.game.state = 'ended'
const finalRankings = Array.from(this.game.players.values())
.sort((a, b) => b.score - a.score)
.map(p => ({
playerId: p.id,
name: p.name,
score: p.score,
answers: p.answers,
}))
await this.saveGameState()
this.room.broadcast(
JSON.stringify({
type: 'game_ended',
finalRankings,
} as ServerMessage),
)
}
private async handleHostEndGame(sender: Party.Connection) {
if (!this.game || sender.id !== this.hostId) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Only the host can end the game',
} as ServerMessage),
)
return
}
await this.endGame()
}
private async handleRestartGame(sender: Party.Connection) {
if (!this.game || sender.id !== this.hostId) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Only the host can restart the game',
} as ServerMessage),
)
return
}
// Reset all players' scores and answers
for (const player of this.game.players.values()) {
player.score = 0
player.answers = []
}
// Reset game state
this.game.state = 'waiting'
this.game.currentQuestionIndex = -1
this.answeredPlayers.clear()
if (this.questionTimer) {
clearTimeout(this.questionTimer)
this.questionTimer = null
}
await this.saveGameState()
// Broadcast that game has been reset to waiting
this.room.broadcast(
JSON.stringify({
type: 'game_state',
state: this.game.state,
players: Array.from(this.game.players.values()).map(p => ({
id: p.id,
name: p.name,
score: p.score,
})),
} as ServerMessage),
)
}
private sendGameState(conn: Party.Connection) {
if (!this.game)
return
conn.send(
JSON.stringify({
type: 'game_state',
state: this.game.state,
players: Array.from(this.game.players.values()).map(p => ({
id: p.id,
name: p.name,
score: p.score,
})),
} as ServerMessage),
)
}
private broadcastGameState() {
if (!this.game)
return
this.room.broadcast(
JSON.stringify({
type: 'game_state',
state: this.game.state,
players: Array.from(this.game.players.values()).map(p => ({
id: p.id,
name: p.name,
score: p.score,
})),
} as ServerMessage),
)
}
private async saveGameState() {
if (!this.game)
return
await this.room.storage.put('game', {
...this.game,
players: Array.from(this.game.players.entries()),
})
}
private broadcastConnectionCount() {
const count = [...this.room.getConnections()].length
this.room.broadcast(
JSON.stringify({
type: 'connection_count',
count,
} as ServerMessage),
)
}
}
KahootServer satisfies Party.Worker
{
"$schema": "https://www.partykit.io/schema.json",
"name": "tools",
"main": "party/index.ts",
"compatibilityDate": "2025-09-02",
"parties": {
"polls": "party/polls.ts",
"kahoot": "party/kahoot.ts",
"feedback": "party/feedback.ts",
"feelings": "party/feelings.ts"
}
}
import type * as Party from 'partykit/server'
import { timestamp } from '@setemiojo/utils'
// Types for poll messages
interface PollOption {
id: string
text: string
votes: number
}
interface Poll {
id: string
question: string
options: PollOption[]
isActive: boolean
createdBy: string
createdAt: number
}
type ClientMessage
= | { type: 'create_poll', question: string, options: string[] }
| { type: 'vote', optionId: string }
| { type: 'end_poll' }
| { type: 'get_results' }
type ServerMessage
= | { type: 'poll_created', poll: Poll }
| { type: 'poll_updated', poll: Poll }
| { type: 'poll_ended', poll: Poll }
| { type: 'error', message: string }
| { type: 'connection_count', count: number }
export default class PollsServer implements Party.Server {
private poll: Poll | null = null
private voters: Set<string> = new Set()
constructor(readonly room: Party.Room) {}
async onStart() {
// Load poll state from storage
const storedPoll = await this.room.storage.get<Poll>('poll')
if (storedPoll) {
this.poll = storedPoll
}
const storedVoters = await this.room.storage.get<string[]>('voters')
if (storedVoters) {
this.voters = new Set(storedVoters)
}
}
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
console.log(
`Connected to poll room:
id: ${conn.id}
room: ${this.room.id}
url: ${new URL(ctx.request.url).pathname}`,
)
// Send current poll state to new connection
if (this.poll) {
conn.send(
JSON.stringify({
type: 'poll_updated',
poll: this.poll,
} as ServerMessage),
)
}
// Send connection count to all
this.broadcastConnectionCount()
}
onClose(conn: Party.Connection) {
this.broadcastConnectionCount()
}
async onMessage(message: string | ArrayBuffer, sender: Party.Connection) {
if (typeof message !== 'string')
return
try {
const data: ClientMessage = JSON.parse(message)
switch (data.type) {
case 'create_poll':
await this.handleCreatePoll(data, sender)
break
case 'vote':
await this.handleVote(data, sender)
break
case 'end_poll':
await this.handleEndPoll(sender)
break
case 'get_results':
this.sendPollState(sender)
break
}
}
catch (error) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Invalid message format',
} as ServerMessage),
)
}
}
private async handleCreatePoll(
data: { question: string, options: string[] },
sender: Party.Connection,
) {
// Only allow creating a new poll if none exists or the current one is ended
if (this.poll && this.poll.isActive) {
sender.send(
JSON.stringify({
type: 'error',
message: 'A poll is already active',
} as ServerMessage),
)
return
}
// Create new poll
this.poll = {
id: crypto.randomUUID(),
question: data.question,
options: data.options.map((text, index) => ({
id: `option-${index}`,
text,
votes: 0,
})),
isActive: true,
createdBy: sender.id,
createdAt: timestamp(),
}
// Reset voters
this.voters.clear()
// Save to storage
await this.room.storage.put('poll', this.poll)
await this.room.storage.put('voters', Array.from(this.voters))
// Broadcast to all connections
this.room.broadcast(
JSON.stringify({
type: 'poll_created',
poll: this.poll,
} as ServerMessage),
)
}
private async handleVote(
data: { optionId: string },
sender: Party.Connection,
) {
if (!this.poll) {
sender.send(
JSON.stringify({
type: 'error',
message: 'No active poll',
} as ServerMessage),
)
return
}
if (!this.poll.isActive) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Poll has ended',
} as ServerMessage),
)
return
}
// Check if user already voted
if (this.voters.has(sender.id)) {
sender.send(
JSON.stringify({
type: 'error',
message: 'You have already voted',
} as ServerMessage),
)
return
}
// Find the option and increment vote
const option = this.poll.options.find(opt => opt.id === data.optionId)
if (!option) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Invalid option',
} as ServerMessage),
)
return
}
option.votes++
this.voters.add(sender.id)
// Save to storage
await this.room.storage.put('poll', this.poll)
await this.room.storage.put('voters', Array.from(this.voters))
// Broadcast updated poll to all connections
this.room.broadcast(
JSON.stringify({
type: 'poll_updated',
poll: this.poll,
} as ServerMessage),
)
}
private async handleEndPoll(sender: Party.Connection) {
if (!this.poll) {
sender.send(
JSON.stringify({
type: 'error',
message: 'No active poll',
} as ServerMessage),
)
return
}
// Only the creator can end the poll
if (this.poll.createdBy !== sender.id) {
sender.send(
JSON.stringify({
type: 'error',
message: 'Only the poll creator can end the poll',
} as ServerMessage),
)
return
}
this.poll.isActive = false
await this.room.storage.put('poll', this.poll)
// Broadcast poll ended
this.room.broadcast(
JSON.stringify({
type: 'poll_ended',
poll: this.poll,
} as ServerMessage),
)
}
private sendPollState(conn: Party.Connection) {
if (this.poll) {
conn.send(
JSON.stringify({
type: 'poll_updated',
poll: this.poll,
} as ServerMessage),
)
}
}
private broadcastConnectionCount() {
const count = [...this.room.getConnections()].length
this.room.broadcast(
JSON.stringify({
type: 'connection_count',
count,
} as ServerMessage),
)
}
}
PollsServer satisfies Party.Worker
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment