Sister Brawl#

Status: 🔧 In Development — frontend complete with solo practice mode, Nakama match handler implemented Version: 0.2.0 Path: games/sisterbrawl/ (frontend) · nakama-modules/ (backend)

A chaotic multiplayer fighting game — 2-8 players, 6 unique characters, team-based 3D combat in Threlte (Svelte-native Three.js). Solo practice mode with 2D canvas renderer now works in headless Chrome.


Architecture#

┌─────────────────────┐      ┌──────────────────────────────┐
│  Browser Client     │      │  Funday Platform              │
│                     │      │                               │
│  SvelteKit + Threlte│◄───►│  Nakama (Go) — match handler  │
│  WebSocket (/ws)    │      │  K3s + Agones                │
│  GameInput (kbd)    │      │  Traefik :443 → Nakama :7350  │
└─────────────────────┘      └──────────────────────────────┘

Integration type: svelte-component (native mount via NativeGameHost) — not an iframe plugin. Session mode: dedicated-native-lobby.

Entry point: games/sisterbrawl/src/Game.svelte

Nakama proxy: sisterbrawl_match (registered in funday-plugin.json backend config as nakama-authoritative)


What Ships Today#

LayerStatusNotes
Frontend✅ Built & runninggames/sisterbrawl/ — Svelte 5 runes, 2D+3D dual renderer, ~1,200 LOC
Solo Practice Mode✅ WorkingClient-side only, no Nakama needed, 2D canvas fallback, bot AI
Nakama match handler✅ Implemented (TS)server/match_handler.ts — 1,182 LOC, 60Hz tick, authoritative
Nakama metrics✅ Implementedserver/metrics.ts — 187 LOC
3D Multiplayer⏳ Pending Nakama registrationMatch handler exists but not yet wired into nakama-modules/index.ts

Frontend File Inventory#

FileLOCPurpose
src/Game.svelte~1,200Main game component — dual 2D/3D renderer, solo practice, bot AI
src/stores/gameStore.svelte.ts472Server state, client prediction, reconciliation, interpolation
src/types/index.ts253Core Entity, CharacterDef, AbilityDef, MatchState, EntityState enum
src/lib/audio.ts200Web Audio API procedural sounds (14+ SFX, BGM loop)
src/lib/particleSystem.ts80Data-only particle pool (200 particles), effects for hit sparks, KO, specials
src/lib/screenShake.tsImpact-proportional camera shake, prefers-reduced-motion support
src/components/CharacterSelect.svelteCharacter grid with ability tooltips
src/components/GameHUD.svelteHP bars, score, mute button
src/components/CountdownOverlay.svelteMatch start countdown animation
src/components/ResultsOverlay.svelteVictory/defeat screen with stats
src/components/ReplayControls.sveltePlayback scrub for recorded replays
src/components/SpectatorViewport.svelteSpectator camera mode
src/stores/spectatorStore.svelte.tsSpectator state management
server/match_handler.ts1,182Authoritative Nakama match handler — 60Hz, input broadcast, state sync
server/metrics.ts187Match metrics collection

Characters (6 defined)#

IDNameRoleHPSpecial
emberEmberAggressive100Burn DoT (2 DPS × 3s, 8s CD)
frostFrostDefensive90Ice wall (blocks projectiles, 10s CD)
voltVoltMobile80Dash strike chains to nearby enemies (7s CD)
shadeShadeAssassin85Teleport + crit guarantee (9s CD)
terraTerraTank130Ground pound stun (12s CD)
aquaAquaSupport95Heal shield + projectile reflect (6s CD)

Solo Practice Mode#

How It Works#

Solo mode is client-side only — no Nakama server connection needed. It auto-activates 2 seconds after the game component mounts if no Nakama match ID is present.

Flow:

  1. Game component mounts → onMount fires
  2. onMount checks store.state.matchId — if null, stays in 2D mode (no WebGL Canvas mounted)
  3. After 2s, startSoloPractice() spawns player (Ember) and bot (Frost) locally
  4. Phase set to 'playing'isSoloMode derived becomes true
  5. 2D canvas renderer draws arena, entities, HUD at 60fps
  6. Bot AI chases player, attacks when close, jumps randomly

Activation code path:

onMount → check matchId → null → setTimeout(2s) → startSoloPractice()
  → spawn entities → set phase='playing' → isSoloMode=true → draw2DFrame() loop

2D Canvas Renderer#

When isSoloMode is true (matchId === null && phase === 'playing'), the 2D <canvas> element is shown and a dedicated render loop draws:

  • Dark blue arena grid with walls
  • Player entity (red circle with gradient, HP bar, name label, state glow)
  • Bot entity (blue circle, HP bar, AI state indicators)
  • Particles (hit sparks, attack emissions, jumps)
  • Projectiles (yellow circles)
  • Camera transform converting world coords to screen coords
  • Arena boundary walls

Canvas init: Uses bind:this + $effect watching isSoloMode + lazy-init fallback in draw loop. Canvas element is always mounted (just hidden with style:display) for reliable bind:this timing.

Headless Chrome Compatibility#

Problem: Headless Chrome (no GPU) can’t create WebGL contexts. The Threlte <Canvas> component throws during mount, which prevents onMount from firing in Svelte 5’s lifecycle.

Solution: Two-phase mount with $state gate:

let show3DCanvas = $state(false);  // starts false, Canvas NOT mounted

onMount(() => {
  if (store.state.matchId) {
    show3DCanvas = true;  // only enable 3D if Nakama match exists
  }
  // start solo practice if no match...
});

The 3D <Canvas> only mounts via {#if show3DCanvas} AFTER onMount confirms a Nakama match. In solo mode, show3DCanvas stays false and no WebGL init is attempted.

Key Implementation Details#

  • Entity physics: 60Hz setInterval, gravity -20, friction 0.85, arena bounds ±28
  • Bot AI: Chase player when distance > 2, attack when close (5 damage, 30-tick cooldown), random jump (1% chance per frame)
  • Store reactivity: Entities stored in SvelteMap, replaced each tick for reactivity: state.entities = new SvelteMap(state.entities)
  • Render loop: requestAnimationFrame (3D) + setInterval at 60Hz (2D fallback for headless Chrome rAF throttling)

Systems#

🎯 Client-Side Prediction + Server Reconciliation#

Local player movement predicted immediately at 60Hz. When server state arrives, unacked inputs are replayed. Smooth correction over 10 frames when drift > 0.2 units. Remote entities interpolate between snapshots.

Implementation: gameStore.svelte.tsapplyInput(), reconcile(), interpolateRemotes()

⚡ Character Ability System#

Client-side only (server knows “special used” but not effect logic). AbilityDef on each CharacterDef with cooldown, duration, tooltip. Active effects ticked via updateEffects(dt) each frame.

export interface AbilityDef {
  cooldown: number;
  duration: number;
  type: string;       // 'burn' | 'ice-wall' | 'dash-strike' | 'teleport-crit' | 'stun' | 'shield'
  dmgMod?: number;
  tooltip?: string;
}

🔊 Procedural Audio#

Web Audio API — no external files. 14+ procedural SFX (attacks, jumps, per-character specials, KO, countdown, victory). 120 BPM battle music loop. Master/SFX/Music volume with localStorage persistence.

💥 Particle VFX#

Data-only pool (200 particles). Effects: hit sparks, KO bursts, block shields, character specials, footstep dust.

📳 Screen Shake#

Impact-proportional on hits, KOs, specials. Character-specific intensities (terra > ember > frost). Quadratic decay, stackable. Respects prefers-reduced-motion.

🔄 Replay System#

Snapshots recorded at tick rate. ReplayData stores entity/projectile state per tick. ReplayControls.svelte provides scrub UI.

👁️ Spectator Mode#

SpectatorViewport.svelte + spectatorStore.svelte.ts. Free camera for watching live matches.


Entity State Machine#

Idle → Moving / Jumping / Attacking / Blocking / Special
Any → Hitstun → (recovery) → Idle
Any → KO → (respawn timer) → Idle

States are EntityState enum (0–7). Transitions trigger VFX and sound events in Game.svelte’s unified render loop.


Input System#

Bitmask: 1 = attack, 2 = jump, 4 = special, 8 = block. X/Y axes as -1, 0, 1.

export const BTN_ATTACK = 1;
export const BTN_JUMP = 2;
export const BTN_SPECIAL = 4;
export const BTN_BLOCK = 8;

Keyboard → gameStore → sent via Nakama socket each frame.

Solo mode controls: WASD/Arrows = move, Z/J = attack, X/K = jump, C/L = special, V/I = block


Headless Chrome Rendering — Lessons Learned#

Problem Chain#

  1. WebGL in headless Chrome: No GPU → SwiftShader can’t rasterize Three.js → <Canvas> throws during mount
  2. onMount never fires: In Svelte 5, if a child component throws during the parent’s mount cycle, onMount callbacks don’t execute
  3. No WebGL = no solo mode: All auto-activation logic was in onMount, so solo mode never started

Solution Architecture#

┌─ Template ──────────────────────────────────────────────────────┐
│ <canvas (2D) [always mounted, style:display toggled]>          │
│ {#if show3DCanvas} <Canvas (3D) [conditionally mounted]> {/if}│
└────────────────────────────────────────────────────────────────┘

┌─ Script ───────────────────────────────────────────────────────┤
│ let show3DCanvas = $state(false); // 3D OFF by default         │
│                                                                │
│ onMount(() {                    // fires safely (no WebGL)     │
│   if (store.state.matchId) show3DCanvas = true;  // enable 3D  │
│   // start solo practice after 2s...                           │
│ });                                                            │
│                                                                │
│ $effect(() => {                 // reliable canvas init         │
│   if (isSoloMode) tryInitCanvas();                             │
│ });                                                            │
└────────────────────────────────────────────────────────────────┘

Svelte 5 Gotchas Encountered#

  • class:hidden={...} doesn’t work on Threlte components (<Canvas>) — must use wrapper <div> or style:display
  • {#if} block self-closing (<canvas ... />) causes element_invalid_self_closing_tag error — must use <canvas></canvas>
  • Unicode arrow characters () in HTML comments break Svelte parser — use ASCII -> or --> only
  • Plain let vars with bind:this aren’t reactive — $effect watching a $derived signal is required for reliable init
  • style:display values must be quoted strings: 'block' not block

Deployment#

Frontend runs via systemd funday-frontend (SvelteKit on port 3000, proxied through Traefik).

cd /home/usr/funday
bash scripts/build-atomic.sh        # builds games → vite → atomic swap → restart

Health checks:

curl -sf http://127.0.0.1:3000/health       # frontend
curl -sf https://funday.gg/v2/healthcheck    # nakama

Verify solo mode in headless Chrome:

# Chrome runs on display :1 with CDP on port 9222
node -e "
const ws = new WebSocket('ws://127.0.0.1:9222/devtools/page/<id>');
// navigate to /play/sisterbrawl, wait 10s, check console for [SOLO] logs

What’s Missing (Next Steps)#

Blockers#

  • Nakama module registrationmatch_handler.ts exists but not imported in nakama-modules/index.ts
  • Build + rollout — Nakama modules need rebuild + K3s rollout
  • Match creation RPC — client needs sisterbrawl.create_match RPC

Phase 4: Testing & Hardening#

  • E2E multiplayer smoke test (Playwright)
  • Performance profiling (entity count, GC pressure, frame time)
  • Cross-browser testing (Safari, Firefox)

Phase 5: Deploy & Monitor#

  • Nakama module build + K3s rollout
  • Grafana dashboard for match metrics

Nice-to-Have#

  • Ranked matchmaking with Elo
  • Multiple arenas
  • Victory/defeat animations
  • Touch controls for mobile
  • Configurable bot difficulty

Rules & Conventions#

  • Game lives in games/sisterbrawl/never import frontend/src/lib/* (enforced by check-game-boundaries.mjs)
  • All Svelte files use runes ($state, $derived, $effect) — no export let, no $:
  • Stores are classes in *.svelte.ts with direct .svelte.ts import
  • <T.PerspectiveCamera> must use makeDefault prop
  • Bridge to host via platformBus (native svelte-component, not postMessage)
  • HTML comments must use ASCII only — no Unicode arrows or em-dashes in Svelte template comments