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 → 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 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 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
| Gotcha | Solution |
|---|---|
class:hidden={...} doesn’t work on Threlte <Canvas> | Use wrapper <div> or style:display |
{#if} block self-closing (<canvas ... />) causes error | Use <canvas></canvas> |
Unicode arrows (→) in HTML comments break parser | Use 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 aggressionEntity Physics (Solo)
| Parameter | Value |
|---|---|
| Tick rate | 60Hz via setInterval |
| Gravity | -20 units/s² |
| Friction | 0.85 |
| Arena bounds | ±28 units (56×56 arena) |
| Jump velocity | 10 units/s |
| Knockback | 8 units/s |
| Hitstun | 20 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 skillQuick 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
| File | Purpose |
|---|---|
src/Game.svelte | Main component — dual renderer, solo activation |
src/stores/gameStore.svelte.ts | Reactive store — solo mode state, bot AI |
src/lib/particleSystem.ts | Particle pool for solo mode |
src/lib/screenShake.ts | Screen shake for solo hits |
src/lib/audio.ts | Audio for solo mode |
Related Pages
- Architecture → Solo Practice Flow — Data flow diagram
- Audio — Systems used in solo mode
- Deployment → Solo Mode Verification — CI verification script