Sister Brawl — Architecture
Pure system architecture. File inventory → Reference. API contracts → Reference.
System Diagram
graph TB subgraph Browser["Browser Client"] Game["Game.svelte<br/>(2794 LOC)"] Game --> Canvas3D["Threlte [Canvas]<br/>3D Arena + Entities"] Game --> Canvas2D["2D Canvas<br/>Solo/Headless Fallback"] Game --> Store["gameStore.svelte.ts<br/>(895 LOC)"] Store --> Prediction["Client Prediction<br/>60Hz"] Store --> Reconciliation["Server Reconciliation<br/>10-frame correction"] Store --> Interpolation["Entity Interpolation<br/>Remote lerp"] Store --> HitStop["Hit-Stop<br/>Freeze frames"] Game --> Audio["audio.ts<br/>Web Audio API"] Game --> Particles["particleSystem.ts<br/>Pool: 200"] Game --> Shake["screenShake.ts<br/>Stackable, quadratic"] Game --> UI["UI Components<br/>CharacterSelect, HUD, VictoryScreen, Tutorial"] end Browser <--->|WebSocket /ws| Nakama["Nakama Server<br/>(TypeScript Runtime)"] subgraph Platform["Funday Platform (K3s)"] Nakama --> Handler["match_handler.ts<br/>(3539 LOC)"] Handler --> Physics["60Hz Tick<br/>Input → Physics → Broadcast"] Handler --> Specials["6 Character Specials<br/>Server-authoritative"] Handler --> Hazards["Arena Hazards<br/>Lava, Ice, Trees"] Handler --> Progression["XP/Level/Elo<br/>Nakama Storage"] Handler --> Replay["Replay Recording<br/>10Hz snapshots"] Handler --> Spectator["Free Camera<br/>Spectator Mode"] Nakama --> Metrics["metrics.ts<br/>199 LOC"] Metrics --> Sidecar["Metrics Sidecar :9101<br/>Python → Prometheus"] end Sidecar --> Prometheus["Prometheus"] Prometheus --> Grafana["Grafana Dashboard<br/>:9119/d/sisterbrawl"]
Integration Points
| Component | Technology | Interface |
|---|---|---|
| Frontend ↔ Nakama | WebSocket | /ws with Nakama socket protocol |
| Frontend ↔ BFF | HTTP + Cookie | /api/plugins/rpc/ (game RPCs) |
| Nakama ↔ Metrics | RPC + HTTP | sisterbrawl_metrics_plain → Prometheus |
| Nakama ↔ K8s | Agones SDK | Fleet allocation, health checks |
| Frontend ↔ CDN | HTTPS | Static assets via Traefik → Nginx |
Data Flow
Match Lifecycle
sequenceDiagram participant C as Client participant N as Nakama participant MH as Match Handler participant S as Spectator C->>N: find_match_v3 RPC N->>MH: matchCreate -> matchInit() C->>MH: joinMatch() -> matchJoinAttempt()/matchJoin() Note over C,MH: Both players READY MH->>C: countdown -> matchLoop() starts (60Hz) loop 60Hz loop C->>MH: send INPUT MH->>C: physics update -> STATE_UPDATE broadcast end Note over C,MH: Match end (kills/timeout) MH->>C: GAME_OVER + progression C->>C: ResultsOverlay (XP/levels) S->>N: sisterbrawl_spectate RPC
Solo Practice Flow
flowchart TD Mount["Game.svelte mounts"] --> onMount["onMount() check matchId"] onMount -->|matchId is null| Solo["Solo Practice Mode"] Solo --> Timeout["2s timeout -> startSoloPractice()"] Timeout --> Spawn["Spawn player (Ember) & bot (Frost) locally"] Spawn --> Phase["Set phase = 'playing' & isSoloMode = true"] Phase --> Render["2D canvas render loop at 60fps"] Render --> AI["Bot AI runs client-side (chase, attack, jump)"]
Key Constants (Single Source of Truth)
| Constant | Value | Authority |
|---|---|---|
TICK_RATE | 60 Hz | Server & Client |
MATCH_TIMEOUT | 300s (18,000 ticks) | Server |
KILL_THRESHOLD | 10 kills | Server |
ARENA_SIZE | 56×56 (±28 units) | Server & Client |
GRAVITY | -20 m/s² | Server & Client |
ATTACK_RANGE | 1.5 units | Server |
ATTACK_COOLDOWN | 15 ticks (250ms) | Server |
JUMP_VELOCITY | 10 m/s | Server & Client |
KNOCKBACK | 8 m/s | Server |
HITSTUN | 20 ticks (333ms) | Server & Client |
BLOCK_REDUCTION | 80% | Server |
BASE_K (Elo) | 32 | Server |
MIN_RATING (Elo) | 100 | Server |
PROVISIONAL_K | 64 (first 10 matches) | Server |
PARTICLE_POOL_MAX | 200 | Client |
DAMAGE_NUMBER_POOL | 50 | Client |
File Structure (Logical)
games/sisterbrawl/
├── funday-plugin.json # Game manifest
├── src/ # Frontend (Svelte 5 runes)
│ ├── Game.svelte # Main component, dual renderer
│ ├── stores/ # Reactive stores (.svelte.ts)
│ ├── components/ # UI components
│ ├── lib/ # Core systems (audio, particles, shake, themes)
│ └── types/ # Shared TypeScript types
└── server/ # Nakama TypeScript runtime
├── match_handler.ts # Authoritative match logic
└── metrics.ts # 199 LOC
Nakama Module Registration
// nakama-modules/index.ts
import {
matchInit,
matchJoinAttempt,
matchJoin,
matchLeave,
matchLoop,
matchTerminate,
} from "@games/sisterbrawl/server/match_handler"
initializer.registerMatch("sisterbrawl_match", {
matchInit,
matchJoinAttempt,
matchJoin,
matchLeave,
matchLoop,
matchTerminate,
})
// RPCs (11 total)
initializer.registerRpc("sisterbrawl_find_spectatable", rpcFindSpectatable)
initializer.registerRpc("sisterbrawl_spectate", rpcSpectate)
initializer.registerRpc("sisterbrawl_get_replay", rpcGetReplay)
initializer.registerRpc("sisterbrawl_get_leaderboard", rpcGetLeaderboard)
initializer.registerRpc("sisterbrawl_get_player_stats", rpcGetPlayerStats)
initializer.registerRpc("sisterbrawl_record_referral", rpcRecordReferral)
initializer.registerRpc("sisterbrawl_metrics_plain", rpcMetricsPlain)
initializer.registerRpc("sisterbrawl_get_active_counts", rpcGetActiveCounts)
initializer.registerRpc("sisterbrawl_create_match", rpcCreateMatch)Match Label Schema (Nakama)
interface MatchLabel {
open: "true" | "false" // true = joinable in lobby
mode: "ranked" | "casual" | "custom"
arenaTheme: "volcanic" | "frozen" | "forest" | "auto"
hostId: string // Nakama user ID of host
playerCount: number // Current players
maxPlayers: number // 2-8
version: string // Game manifest version
}Arena Themes (Deterministic + Host Override)
| Theme | Palette | Hazards |
|---|---|---|
| Volcanic | Red/orange/black | 4 lava pools (3 dmg + 2 DPS burn/2s) + moving platform |
| Frozen | Blue/white/cyan | 4 ice crystals (2 dmg + 1 DPS slow/1s) + slippery center |
| Forest | Green/brown/gold | 5 trees (block movement/projectiles) + moving platform |
Sync Contract: Host selects theme via opcode 8 → all clients & spectators converge via STATE_UPDATE payload containing matchId + arenaTheme.
Client-Side Prediction + Reconciliation
flowchart TD Input["Local Input (60Hz)"] --> Apply["applyInput() -> predicted state"] Apply --> Send["sendOpcode(0, INPUT) -> Nakama"] Send --> Server["Server matchLoop() -> authoritative physics"] Server --> Broadcast["STATE_UPDATE broadcast (60Hz)"] Broadcast --> Reconcile["reconcile() -> replay unacked inputs -> smooth correct (10 frames if drift > 0.2)"] Broadcast --> Interpolate["interpolateRemotes() -> lerp between snapshots"]
Platform Bridge (Injected Props)
interface PlatformProps {
matchId: string | null
userId: string
username: string
sessionToken: string
isHost: boolean
matchLabel: MatchLabel
onMatchState: (state: MatchState) => void
onMatchEnd: (results: MatchResults) => void
onPlayerJoin: (player: PlayerInfo) => void
onPlayerLeave: (userId: string) => void
onError: (error: string) => void
sendOpcode: (opcode: number, data: any) => void
rpc: (id: string, payload: any) => Promise<any>
}Injected by NativeGameHost.svelte via platformBus — NOT postMessage.
Related Pages
- Characters — Fighter definitions
- Systems — Core game systems deep-dive
- Solo Practice — Headless Chrome architecture
- Deployment — Build & ops
- Reference — File inventory, API contracts, constants, checklists