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#
| Layer | Status | Notes |
|---|---|---|
| Frontend | ✅ Built & running | games/sisterbrawl/ — Svelte 5 runes, 2D+3D dual renderer, ~1,200 LOC |
| Solo Practice Mode | ✅ Working | Client-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 | ✅ Implemented | server/metrics.ts — 187 LOC |
| 3D Multiplayer | ⏳ Pending Nakama registration | Match handler exists but not yet wired into nakama-modules/index.ts |
Frontend File Inventory#
| File | LOC | Purpose |
|---|---|---|
src/Game.svelte | ~1,200 | Main game component — dual 2D/3D renderer, solo practice, bot AI |
src/stores/gameStore.svelte.ts | 472 | Server state, client prediction, reconciliation, interpolation |
src/types/index.ts | 253 | Core Entity, CharacterDef, AbilityDef, MatchState, EntityState enum |
src/lib/audio.ts | 200 | Web Audio API procedural sounds (14+ SFX, BGM loop) |
src/lib/particleSystem.ts | 80 | Data-only particle pool (200 particles), effects for hit sparks, KO, specials |
src/lib/screenShake.ts | — | Impact-proportional camera shake, prefers-reduced-motion support |
src/components/CharacterSelect.svelte | — | Character grid with ability tooltips |
src/components/GameHUD.svelte | — | HP bars, score, mute button |
src/components/CountdownOverlay.svelte | — | Match start countdown animation |
src/components/ResultsOverlay.svelte | — | Victory/defeat screen with stats |
src/components/ReplayControls.svelte | — | Playback scrub for recorded replays |
src/components/SpectatorViewport.svelte | — | Spectator camera mode |
src/stores/spectatorStore.svelte.ts | — | Spectator state management |
server/match_handler.ts | 1,182 | Authoritative Nakama match handler — 60Hz, input broadcast, state sync |
server/metrics.ts | 187 | Match metrics collection |
Characters (6 defined)#
| ID | Name | Role | HP | Special |
|---|---|---|---|---|
ember | Ember | Aggressive | 100 | Burn DoT (2 DPS × 3s, 8s CD) |
frost | Frost | Defensive | 90 | Ice wall (blocks projectiles, 10s CD) |
volt | Volt | Mobile | 80 | Dash strike chains to nearby enemies (7s CD) |
shade | Shade | Assassin | 85 | Teleport + crit guarantee (9s CD) |
terra | Terra | Tank | 130 | Ground pound stun (12s CD) |
aqua | Aqua | Support | 95 | Heal 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:
- Game component mounts →
onMountfires onMountchecksstore.state.matchId— if null, stays in 2D mode (no WebGL Canvas mounted)- After 2s,
startSoloPractice()spawns player (Ember) and bot (Frost) locally - Phase set to
'playing'→isSoloModederived becomestrue - 2D canvas renderer draws arena, entities, HUD at 60fps
- 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() loop2D 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) +setIntervalat 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.ts — applyInput(), 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) → IdleStates 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#
- WebGL in headless Chrome: No GPU → SwiftShader can’t rasterize Three.js →
<Canvas>throws during mount onMountnever fires: In Svelte 5, if a child component throws during the parent’s mount cycle,onMountcallbacks don’t execute- 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>orstyle:display{#if}block self-closing (<canvas ... />) causeselement_invalid_self_closing_tagerror — must use<canvas></canvas>- Unicode arrow characters (
→) in HTML comments break Svelte parser — use ASCII->or-->only - Plain
letvars withbind:thisaren’t reactive —$effectwatching a$derivedsignal is required for reliable init style:displayvalues must be quoted strings:'block'notblock
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 → restartHealth checks:
curl -sf http://127.0.0.1:3000/health # frontend
curl -sf https://funday.gg/v2/healthcheck # nakamaVerify 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] logsWhat’s Missing (Next Steps)#
Blockers#
- Nakama module registration —
match_handler.tsexists but not imported innakama-modules/index.ts - Build + rollout — Nakama modules need rebuild + K3s rollout
- Match creation RPC — client needs
sisterbrawl.create_matchRPC
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 importfrontend/src/lib/*(enforced bycheck-game-boundaries.mjs) - All Svelte files use runes (
$state,$derived,$effect) — noexport let, no$: - Stores are classes in
*.svelte.tswith direct.svelte.tsimport <T.PerspectiveCamera>must usemakeDefaultprop- Bridge to host via
platformBus(native svelte-component, notpostMessage) - HTML comments must use ASCII only — no Unicode arrows or em-dashes in Svelte template comments