# Krunker Civilian Client Cross-platform Electron-based game client for [Krunker.io](https://krunker.io). Loads the game directly in a BrowserWindow with GPU optimizations, unlimited FPS, persistent sessions, ad blocking, resource swapping, custom matchmaker, userscript engine, and Discord Rich Presence. ## Quick Start ```bash npm install npm start # Build all targets + launch Electron ``` For development with sourcemaps: ```bash npm run dev # Builds in dev mode + launches Electron ``` ## Scripts | Script | Description | |--------|-------------| | `npm run dev` | Development build + launch | | `npm start` | Full build (main + preload) + launch | | `npm run build` | Build both Vite targets | | `npm run build:main` | Build main process only | | `npm run build:preload` | Build preload script only | | `npm run dist:win` | Build + package for Windows (NSIS installer + portable) | | `npm run dist:linux` | Build + package for Linux (AppImage + deb) | | `npm run dist:all` | Build + package for all platforms | | `npm run clean` | Remove `dist/` and `out/` directories | ## Architecture ### Custom Patched Electron 42 This client uses a custom-patched Electron 42 (Chromium 134, Node 24). The `--disable-frame-rate-limit` Chromium flag — required for unlimited FPS — causes input starvation ("aim freeze") on Chromium 84+. At uncapped frame rates (300+ FPS), the compositor floods the main thread task queue and input events get delayed 50-300ms, then snap to catch up. The `ImplLatencyRecovery`/`MainLatencyRecovery` features mitigated this on Chromium 87–93 but were removed in Chromium 94. #### The Patch A single-line change in Chromium's main thread scheduler (`main_thread_scheduler_impl.cc`) demotes input tasks from `kHighestPriority` to `kNormalPriority`, allowing the scheduler's built-in anti-starvation logic to fairly interleave input and compositor work: ```diff case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput: - return TaskPriority::kHighestPriority; + return TaskPriority::kNormalPriority; ``` Benchmarked via CDP `Input.dispatchMouseEvent`: | Metric | Before | After | |--------|--------|-------| | p99 latency | 97ms | 34ms | | Max latency | 308ms | 38ms | | Events >50ms | 8.6% | 0% | | Frames rendered | baseline | +21% | | Mouse events processed | baseline | +9% | The full patch file, GN build args, and step-by-step build instructions are in [`electron-build/`](electron-build/BUILD.md). #### Binary Distribution The patched binary is downloaded automatically during `npm install` via `scripts/download-electron.js` from a Gitea release asset (`electron-patched` tag). The `electron` npm dependency (`npm:electron-nightly@42.0.0-nightly.20260227`) provides TypeScript types and the CLI entry point, but the actual binary comes from the patched download. FPS uncap flags (always applied when enabled): ``` disable-frame-rate-limit + disable-gpu-vsync + max-gum-fps=9999 ``` #### Building from Source Prerequisites: Windows 10/11 x64, ~100 GB disk, 16+ GB RAM, Visual Studio 2022 with C++ workload. ```bash # 1. Install depot_tools and add to PATH git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git # 2. Check out Electron source (~40-60 GB) mkdir C:\electron && cd C:\electron gclient config --name "src/electron" --unmanaged https://github.com/nicedayzhu/electron.git@v42.0.0-nightly.20260227 gclient sync --with_branch_heads --with_tags # 3. Apply the patch cd src git apply path/to/electron-build/input-priority-fix.patch # 4. Configure and build (~2-4 hours for release) gn gen out/Release cp path/to/electron-build/args.release.gn out/Release/args.gn gn gen out/Release ninja -C out/Release electron # 5. Create distributable zip ninja -C out/Release electron:dist_zip ``` See [`electron-build/BUILD.md`](electron-build/BUILD.md) for detailed instructions, testing builds, and verification steps. ### How the Game Loads The game is loaded **directly** in the main `BrowserWindow` via `win.loadURL('https://krunker.io')`. This gives the game full GPU compositing, identical to Chrome. ### Process Model ``` Main Process (src/main/index.ts) | |-- Creates BrowserWindow loading krunker.io directly |-- Manages session (persist:krunker), UA cleaning, permissions |-- Ad blocking + resource swapping via webRequest.onBeforeRequest |-- Configurable keybinds via before-input-event |-- Matchmaker IPC trigger (sends config to preload on hotkey) |-- CSS injection (ad hiding, client settings, matchmaker popup, keybind dialog) |-- File-based logging (electron.log in userData/logs/) |-- IPC handlers for window controls, config, devtools, userscripts |-- Userscript manager (filesystem scanning, tracker, preferences) |-- Auto-updater (Gitea releases API check, Setup.exe download + launch) |-- Discord Rich Presence via raw IPC socket |-- Consent dismiss via polling (no MutationObserver on main frame) | +-- Preload Script (src/preload/index.ts) |-- IPC bridge (window.kpc) |-- Action button grid (folder shortcuts, log viewers, reset/restart) |-- Settings UI hooks (collapsible sections: swapper, matchmaker, chat, translator, accounts, advanced, userscripts) |-- Userscript engine (metadata parser, execution, CSS injection, settings) |-- Matchmaker IPC listener -> fetchGame() in matchmaker.ts |-- Chat translator (Google Translate API, MutationObserver on #chatList) |-- Exit button (exposes Krunker's native #clientExit element) |-- Keybind capture dialog (Crankshaft-style) ``` ### Source Files ``` src/ main/ index.ts Main process — window, IPC, session, ad blocking, keybinds platform.ts OS detection, Chromium GPU/performance flags (per-platform) config.ts electron-store schema, defaults, DEFAULT_KEYBINDS client-ui.ts Injected CSS for settings panel, keybind dialog, matchmaker popup swapper.ts Resource swapper — local asset overrides via custom protocol userscripts.ts Userscript manager — filesystem scanning, tracker.json, preferences discord-rpc.ts Discord Rich Presence via raw IPC socket logger.ts File logging with daily rotation and 7-day retention updater.ts Auto-updater — Gitea releases API check, download, installer launch update-window.ts Update progress BrowserWindow with inline HTML + progress bar preload/ index.ts IPC bridge, settings UI hooks, keybind capture, matchmaker listener, alt manager utils.ts Shared types (SavedConsole, KeybindDef), helpers (escapeHtml, genChatMsg) matchmaker.ts Custom matchmaker — lobby fetch, filtering, popup UI translator.ts Real-time chat translation via Google Translate API userscripts.ts Userscript engine — metadata parser, execution, CSS injection, state ``` ### Build System Two Vite configs build independent targets: | Config | Target | Output | Notes | |--------|--------|--------|-------| | `vite.main.config.ts` | Main process | `dist/main/index.js` (CJS) | Externalizes `electron`, `electron-store`, and Node builtins. Targets Node 20. | | `vite.preload.config.ts` | Preload script | `dist/preload/index.js` (CJS) | Externalizes `electron` and `uuid`. Targets Node 20. | ### Custom Electron Binary The patched Electron binary is managed by `scripts/download-electron.js`: - Downloads from Gitea release tag `electron-patched`, asset `electron-v42.0.0-nightly-patched-win32-x64.zip` - Replaces `node_modules/electron/dist/` with the patched binary - Runs automatically as a `postinstall` script during `npm install` - Skips download if the version file already matches ### Configuration (electron-store) Settings are persisted in `krunker-civilian-config.json` (OS-specific app data directory). Schema defined in `src/main/config.ts`: ```typescript interface AppConfig { window: { width, height, x, y, maximized, fullscreen } performance: { fpsUnlocked, hardwareAccel, gpuPreference } game: { lastServer, socialTabBehaviour, joinAsSpectator } swapper: { enabled, path } userscripts: { enabled, path } matchmaker: { enabled, regions, gamemodes, minPlayers, maxPlayers, minRemainingTime, openServerBrowser } keybinds: { reload, newMatch, copyGameLink, joinFromClipboard, devTools, matchmaker, matchmakerAccept, matchmakerCancel, pauseChat, fullscreenToggle } ui: { showExitButton, deathscreenAnimation, hideMenuPopups } discord: { enabled } translator: { enabled, targetLanguage, showLanguageTag } advanced: { removeUselessFeatures, gpuRasterizing, helpfulFlags, disableAccelerated2D, increaseLimits, lowLatency, experimentalFlags, angleBackend } accounts: SavedAccount[] platform: { detectedOS, gpuBackend } } ``` ### Platform Handling | Feature | Windows | Linux | |---------|---------|-------| | Window chrome | Standard OS frame (`frame: true`) | Standard OS frame (`frame: true`) | | GPU backend | D3D11 via ANGLE (default, configurable) | Default (+ `ozone-platform-hint auto` for Wayland) | | GPU sandbox | Default | Disabled (AppImage FUSE + Mesa driver compat) | Common flags always applied: `disable-backgrounding-occluded-windows`, `ignore-gpu-blocklist`. FPS uncap flags (`disable-frame-rate-limit`, `disable-gpu-vsync`, `max-gum-fps=9999`) applied when enabled. The ANGLE backend is configurable (default D3D11 on Windows; options include OpenGL, Vulkan, D3D9, D3D11on12). Additional Chromium flags (GPU rasterization, useful features, low latency, etc.) are configurable via the Advanced settings panel. ### Session & User-Agent - Uses `partition: 'persist:krunker'` for persistent login/settings across restarts - User-Agent is cleaned to strip the app name identifier (keeps `Electron/` so Krunker enables its Client settings tab) ## Features ### Core Client - Unlimited FPS via custom-patched Electron 42 build - Ad blocking (network-level URL filter + CSS hiding) - Resource swapper (replace game textures, sounds, models with local files) - Custom matchmaker (filter lobbies by region, gamemode, player count, remaining time) - Userscript system (Tampermonkey-style metadata, custom per-script settings, instant toggle via unload) - Chat translator (real-time translation via Google Translate API with language tags) - Exit button in Krunker's sidebar menu (exposes native `#clientExit` element) - Selectable + pausable chat (always-on text selection, F10 to freeze auto-scroll for reading history) - Configurable keybinds with Crankshaft-style rebinding dialog - Configurable ANGLE backend (D3D11, OpenGL, Vulkan, D3D9, D3D11on12 — platform-filtered) - Advanced Chromium flag settings (GPU rasterization, low latency, experimental features, and more) - Client settings tab in Krunker's settings panel with collapsible sections - Action button grid in settings (folder shortcuts, log viewers, reset/restart options) - File-based logging (electron.log in userData/logs/, daily rotation with 7-day retention) - Persistent game session, window state persistence, cookie consent auto-dismiss - Auto-updater (Gitea releases API check, Setup.exe download with progress window, installer launch) - Discord Rich Presence (game state, map, mode, player count) - Death screen animation blocker (prevents CSS animation performance impact at uncapped FPS) - Menu popup hider (hides store ads, stream containers, news popups) ### Alt Manager (Account Switching) - **Account storage** — Save multiple Krunker accounts (label, username, password) - **Quick switch** — In-game "Accounts" menu item opens Krunker's native popup with saved accounts - **Login automation** — Auto-logout + `loginOrRegister()` credential fill - **Settings section** — Full account CRUD in the collapsed "Accounts" settings section - **Credential obfuscation** — Credentials stored with character-shift encoding to prevent casual config reading ## Known Constraints ### MutationObserver Breaks WebGL on Main Frame A `MutationObserver` with `{ childList: true, subtree: true }` on Krunker's main frame DOM causes the WebGL 3D engine to hang indefinitely. Consent dismiss uses polling (`setInterval`) on the main frame instead. MutationObserver is safe in iframes and targeted elements like `#menuWindow`. ### MutationObserver Infinite Loops (Chromium 94+) Observers that modify elements within their observed subtree can cause infinite loops. In Chromium 94+, even `textContent` assignments that don't change the value still create new text nodes (triggering `childList` mutations). Observers must disconnect before DOM modifications and reconnect after. ### Synthetic Events Lack User Activation (Chromium 94+) Events created via `new MouseEvent()` / `new KeyboardEvent()` have `isTrusted: false` and do not provide user activation. Krunker ignores untrusted input events. The client uses `webContents.sendInputEvent()` via IPC to inject trusted input events from the main process. ### Custom Electron Build Required The `--disable-frame-rate-limit` flag causes compositor input starvation on all Chromium versions 84+. The `ImplLatencyRecovery`/`MainLatencyRecovery` features that mitigated this were removed in Chromium 94. Our custom Electron 42 build patches the Chromium main thread scheduler to fairly interleave input and compositor work. See [Building from Source](#building-from-source) above and [`electron-build/BUILD.md`](electron-build/BUILD.md) for full instructions. ### Uncapped FPS and CSS Animations At uncapped frame rates (600+ FPS), Krunker's CSS animations (e.g. death screen slide-in) force a layout recalculation on every frame, which can saturate the renderer main thread. The death screen animation blocker (`ui.deathscreenAnimation`) is enabled by default to prevent this. ## Tech Stack - **Electron** 42 (Chromium 134, Node 24) — Custom-patched build for unlimited FPS - **TypeScript** 5.7 — Type-safe source code - **Vite** 6 — Fast bundler (2 build targets: main + preload) - **electron-store** 8 — JSON config persistence (CJS) - **uuid** 9 — Unique ID generation - **electron-builder** 26 — Cross-platform packaging (NSIS, portable, AppImage, deb) ## Project Structure ``` Krunker-Civilian-Client/ src/ main/ Main process source (10 modules) preload/ Preload script source (5 modules) dist/ Vite build output main/ preload/ out/ electron-builder packaged output build/ Build resources (icons, .desktop file) scripts/ Build scripts (Electron patched binary download) electron-build/ Custom Electron build instructions and patches vite.main.config.ts vite.preload.config.ts electron-builder.yml tsconfig.json package.json ```