Skip to content

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

npm install phaser

Phaser 3 is MIT-licensed. Import from the npm package — do not load from a CDN in production (avoids CSP complications and external dependency).

// Only import the Phaser modules needed to keep bundle size down
import Phaser from 'phaser';

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:

this.rnd = new Phaser.Math.RandomDataGenerator([String(session.seed)]);

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:

const { getScene } = await import(`$lib/minigames/${session.type}`);

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:

onDestroy(() => {
  game?.destroy(true); // true = remove canvas from DOM
  game = null;
});

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
}