Sister Brawl — Solo Practice Mode

Pure solo mode technical reference. Architecture → Architecture. Systems → Systems.


Overview

Solo mode is client-side only — no Nakama server connection needed. It auto-activates 15 seconds after the game component mounts if no Nakama match ID is present (or 100ms via ?embed=1), but physics execution is strictly gated behind the onboarding overlays (Tutorial / ControlsHint / GetReady). For a full breakdown of this overlay flow, see the Training Mode Overlay Autopsy.

Key Achievement: Works in headless Chrome (no GPU) via 2D canvas fallback.


Activation Flow

1. Game.svelte 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

Code Path:

onMount → check matchId → nullsetTimeout(2s) → startSoloPractice()
  → spawn entities → set phase='playing' → isSoloMode=truedraw2DFrame() 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 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 bind:this={canvas2D} style:display={isSoloMode ? 'block' : 'none'} />
{#if show3DCanvas}
  <Canvas>...Threlte 3D scene...</Canvas>
{/if}
 
<!-- Script -->
let show3DCanvas = $state(false) // 3D OFF by default
let isSoloMode = $derived(store.state.matchId === null && store.state.phase === 'playing')
 
onMount(() => {                    // fires safely (no WebGL)
  if (store.state.matchId) show3DCanvas = true  // enable 3D only if Nakama match
  // start solo practice after 2s...
  setTimeout(() => {
    if (!store.state.matchId) startSoloPractice()
  }, 2000)
})
 
$effect(() => {                    // reliable canvas init
  if (isSoloMode) tryInitCanvas()
})

Svelte 5 Gotchas Encountered

GotchaSolution
class:hidden={...} doesn’t work on Threlte <Canvas>Use wrapper <div> or style:display
{#if} block self-closing (<canvas ... />) causes errorUse <canvas></canvas>
Unicode arrows () in HTML comments break parserUse ASCII -> or --> only
Plain let vars with bind:this aren’t reactive$effect watching a $derived signal
style:display values must be quoted strings'block' not block

Bot AI

Behavior:

  • Chase player when distance > 2 units
  • Attack when close (5 damage, 30-tick cooldown)
  • Random jump (1% chance per frame)
  • Training wheels: reduced speed (1.5 vs 3.0), safe distance (3.5 vs 2.0), reduced damage (3 vs 5), no attacks until player moves

Training Wheels Logic:

let playerHasMoved = false
// Set when Math.abs(input.x) > 0.1 || Math.abs(input.y) > 0.1
// Bot idles at safe distance until player moves
// Once player moves → full aggression

Entity Physics (Solo)

ParameterValue
Tick rate60Hz via setInterval
Gravity-20 units/s²
Friction0.85
Arena bounds±28 units (56×56 arena)
Jump velocity10 units/s
Knockback8 units/s
Hitstun20 ticks (333ms)

Store Reactivity

Entities stored in SvelteMap, replaced each tick for reactivity:

state.entities = new SvelteMap(state.entities)

This triggers Svelte 5 reactivity for the render loop.


Render Loop

Primary: requestAnimationFrame (3D)
Fallback: setInterval at 60Hz (2D for headless Chrome rAF throttling)

function draw2DFrame() {
  if (!canvas || !ctx) return
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Camera transform
  // Draw arena grid, walls
  // Draw entities (player + bot)
  // Draw particles
  // Draw projectiles
  // Draw HUD (HP bars, names, state)
  requestAnimationFrame(draw2DFrame)
}

Headless Chrome Verification

Via CDP (Chrome DevTools Protocol)

# Start Chrome with CDP
google-chrome --headless=new --no-sandbox --disable-gpu \
  --remote-debugging-port=9222 --window-size=1280,720 about:blank &
 
# Get WebSocket URL
PAGE_WS=$(curl -sf http://127.0.0.1:9222/json | node -e "
  const pages = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
  const page = pages.find(p => p.url === 'about:blank');
  console.log(page.webSocketDebuggerUrl);
")
 
# Navigate, inject drawing loop, screenshot via ws module
# See: scripts/screenshot-cdp-inject.sh in funday-platform skill

Quick Verification (Browser Console)

// Check canvas renders
const canvas = document.querySelector("canvas")
console.log("Canvas:", canvas.width + "x" + canvas.height)
const ctx = canvas.getContext("2d")
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
let nonZero = 0
for (let i = 0; i < imgData.data.length; i += 4) {
  if (imgData.data[i] || imgData.data[i + 1] || imgData.data[i + 2]) nonZero++
}
console.log("Coverage:", ((nonZero / (canvas.width * canvas.height)) * 100).toFixed(1) + "%")
 
// Check solo mode active
console.log("Solo mode:", window.__gameStore?.isSoloMode)
console.log("Entities:", window.__gameStore?.state?.entities?.size)

Expected Output (Working)

Canvas: 896x493
Coverage: 12.3%
Solo mode: true
Entities: 2

File References

FilePurpose
src/Game.svelteMain component — dual renderer, solo activation
src/stores/gameStore.svelte.tsReactive store — solo mode state, bot AI
src/lib/particleSystem.tsParticle pool for solo mode
src/lib/screenShake.tsScreen shake for solo hits
src/lib/audio.tsAudio for solo mode


Ask Docs

AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

Ctrl+Enter to send