Phaser 3 Integration¶
SvelteKit + Cloudflare Pages frontend. Phaser 3 mounts into a <div> during a SvelteKit page lifecycle. The game receives a session token from the server, plays, and submits results back.
Installation¶
Phaser 3 is MIT-licensed. Import from the npm package — do not load from a CDN in production (avoids CSP complications and external dependency).
Phaser adds ~1MB to the bundle (gzip ~300KB). Lazy-load the minigame page to avoid loading Phaser on every route:
// src/routes/minigame/[type]/+page.ts
export const load = () => import('phaser'); // triggers chunk split
SvelteKit Page Mount Pattern¶
<!-- src/routes/minigame/[type]/+page.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import type { MinigameSession } from '$lib/types';
// Props injected by +page.ts load function
let { session }: { session: MinigameSession } = $props();
let gameContainer: HTMLDivElement;
let game: Phaser.Game | null = null;
onMount(async () => {
const { default: Phaser } = await import('phaser');
const { getScene } = await import(`$lib/minigames/${session.type}`);
game = new Phaser.Game({
type: Phaser.AUTO, // WebGL with Canvas fallback
parent: gameContainer, // mount into this div
width: 800,
height: 600,
backgroundColor: '#1a1a2e',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: getScene(session, handleResult),
});
});
onDestroy(() => {
game?.destroy(true);
game = null;
});
async function handleResult(score: number, duration_ms: number, event_hash: string) {
const res = await fetch('/api/minigame/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: session.token, score, duration_ms, event_hash }),
});
if (!res.ok) {
// Handle validation failure (show error, allow retry)
return;
}
const { result_token, modifier } = await res.json();
// Navigate back to activity confirmation with result_token
goto(`/activity/confirm?result_token=${result_token}`);
}
</script>
<div bind:this={gameContainer} class="minigame-container" />
<style>
.minigame-container {
width: 100%;
max-width: 800px;
aspect-ratio: 4/3;
margin: 0 auto;
}
</style>
Load Function: Session Fetch¶
// src/routes/minigame/[type]/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, url, fetch }) => {
const activity_type = url.searchParams.get('activity_type')!;
const activity_ref_id = url.searchParams.get('activity_ref_id')!;
const res = await fetch('/api/minigame/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activity_type, activity_ref_id }),
});
if (!res.ok) {
// Redirect back with error
throw redirect(303, `/activity?error=session_failed`);
}
const session = await res.json();
return { session };
};
Scene Factory Pattern¶
Each minigame type exports a getScene factory that returns a Phaser.Scene class configured with the session data.
// src/lib/minigames/rhythm.ts
import type { MinigameSession } from '$lib/types';
export function getScene(
session: MinigameSession,
onComplete: (score: number, duration_ms: number, event_hash: string) => void
) {
return class RhythmScene extends Phaser.Scene {
private startTime!: number;
private hits: Array<{ beat_index: number; hit_type: string }> = [];
private rnd!: Phaser.Math.RandomDataGenerator;
constructor() {
super({ key: 'RhythmScene' });
}
init() {
this.rnd = new Phaser.Math.RandomDataGenerator([String(session.seed)]);
}
create() {
this.startTime = this.time.now;
// ... game setup using this.rnd for all random values
}
// Called when game naturally ends (all beats played)
private finish(score: number) {
const duration_ms = this.time.now - this.startTime;
const event_hash = computeEventHash(session.seed, this.hits);
this.game.destroy(true);
onComplete(score, duration_ms, event_hash);
}
};
}
Passing Seed to Scene¶
The seed is a 32-bit integer issued by the server. Pass it as the sole seed to Phaser.Math.RandomDataGenerator:
All random values in the scene (tile positions, timing, sequences, etc.) must come from this.rnd — never from Math.random(). This ensures the server can reproduce the expected game state from the seed.
Event Hash Computation (Client-Side)¶
// src/lib/minigames/hash.ts
export async function computeEventHash(
seed: number,
events: Array<{ [key: string]: number | string | boolean }>
): Promise<string> {
const sorted = [...events].sort((a, b) => {
const ai = (a.beat_index ?? a.tile_index ?? a.spawn_index ?? a.click_index ?? 0) as number;
const bi = (b.beat_index ?? b.tile_index ?? b.spawn_index ?? b.click_index ?? 0) as number;
return ai - bi;
});
const indices = sorted.map(e =>
(e.beat_index ?? e.tile_index ?? e.spawn_index ?? e.circle_index ?? e.click_index ?? 0) as number
);
const hashInput = `${seed}:${events.length}:${indices.join(',')}`;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(hashInput));
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
Called at the end of every scene before submitting results.
Scene File Map¶
| Minigame type | Scene file |
|---|---|
rhythm |
src/lib/minigames/rhythm.ts |
whack |
src/lib/minigames/whack.ts |
sequence |
src/lib/minigames/sequence.ts |
fishing |
src/lib/minigames/fishing.ts |
recipe |
src/lib/minigames/recipe.ts |
spot |
src/lib/minigames/spot.ts |
harvest |
src/lib/minigames/harvest.ts |
maze |
src/lib/minigames/maze.ts |
steady |
src/lib/minigames/steady.ts |
sigil |
src/lib/minigames/sigil.ts |
price |
src/lib/minigames/price.ts |
patience |
src/lib/minigames/patience.ts |
Dynamic import at mount time:
Vite resolves these statically for bundle splitting. All 12 scene files are separate chunks — only the active scene loads.
Canvas Sizing and Mobile¶
Phaser scale config handles responsive sizing:
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
min: { width: 320, height: 240 },
max: { width: 800, height: 600 },
},
All pointer events use this.input.on('pointerdown', ...) — this fires on both mouse click and touch tap. No separate mobile/desktop code paths needed.
For drag-based games (maze), use:
this.input.on('pointermove', (ptr: Phaser.Input.Pointer) => {
if (!ptr.isDown) return;
// handle drag
});
Cleanup¶
Always destroy the Phaser game instance on onDestroy to avoid canvas leaks and memory growth:
SvelteKit navigations trigger onDestroy. This is sufficient for cleanup.
Content Security Policy¶
Phaser uses WebGL and dynamically creates a <canvas>. If a CSP is set on the Pages site, include:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-eval';
img-src 'self' data: blob:;
worker-src blob:;
'unsafe-eval' is required by Phaser's shader compilation. Keep it scoped to the /minigame/ routes via a _headers file if stricter CSP is needed on other routes:
# public/_headers
/minigame/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'; ...
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; ...
Types¶
// src/lib/types.ts (additions for interactive branch)
export interface MinigameSession {
token: string;
seed: number;
type: MinigameType;
min_duration_ms: number;
max_duration_ms: number;
skill_band: number; // 0–6, injected into scene difficulty params
anomaly_intensity?: number; // spot game only
}
export type MinigameType =
| 'rhythm' | 'whack' | 'sequence' | 'fishing'
| 'recipe' | 'spot' | 'harvest' | 'maze'
| 'steady' | 'sigil' | 'price' | 'patience';
export interface MinigameResult {
result_token: string;
modifier: number; // 0.7–1.4
}