Sister Brawl

FieldValue
Status๐Ÿ”ง In development โ€” frontend, solo practice, and Nakama handler are implemented
Manifest version0.1.0
Pathgames/sisterbrawl/ + nakama-modules/ registrations
Players2โ€“8

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 TS runtime 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โœ… Registered in Nakama TS runtimesisterbrawl_match is imported/registered in nakama-modules/index.ts and mapped in game_registry.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.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.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.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) โ†’ 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)

Remaining blockers

  • Nakama module registration โ€” sisterbrawl_match is imported and registered in nakama-modules/index.ts.
  • Build + rollout proof โ€” after handler changes, rebuild nakama-modules and roll out the Nakama deployment.
  • Multiplayer E2E proof โ€” add a two-context Playwright smoke for create/join/play/disconnect.

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