Download

← All updates

Latest update

quark 1.19.4

Everything that changed in this version, and why.

1.19.4 is the production release after 1.19.3: the testing line 1.19.3.<N> promoted as it stands (owner, 2026-11-01: "1.19.4 release checklist is finished ready for takeoff", then yes on the release card). Every change below was made, explained and built in one of those testing builds. The short version is on the updates page.

  • Network protocol 66 (1.19.3 used 53: 54 power; 55-58 rocket thrust and launch speed; 56 walk teleporters; 59 winner stays; 60-61 dash charges (back to two); 62 ball; 63 no air drift; 64 the dash slide; 65 the grapple catches the ball; 66 rocket knockback doubled). A 1.19.4 game only plays with a 1.19.4 server, so update games and servers together.
  • Map format 13 (1.19.3 wrote 10: 11 mode spawn sets, 12 walk teleporters, 13 a race map's default time). Older maps convert on load and on disk, originals kept in maps/old/.
  • Human-made assets: 16.1% (no asset changes since 1.19.3).

Settings: batch 1, the 4s (owner, 2026-10-30: "go ahead and start working on implementing all of the settings ... score all the setting out of 4"; q ticked all 231 on the scored list)

  • MORE pages. CONTROLS and VIDEO + AUDIO were full (the VIDEO page's comments already say a row more pushes SOUND into DEVICES), so each gets a MORE page (footer middle, moreOpen_, screen paths /controls/more, /video/more, --settingstab controls/more). The rest of 1.19.4's settings go on pages like these instead of squeezing the tabs.
  • Mouse (#4). sens_y (vertical as a multiple of SENSITIVITY), invert_y, zoom_sens (a multiple of the existing "same feel" zoom scaling, so x1.00 is today's behaviour). Applied in Game::turn.
  • Scoreboard toggle (#67). score_toggle: the key flips scoresOn_ instead of being held.
  • Background cap and mute (#9). bg_fps (default 60, 0 = FPS LIMIT as usual) caps Game::maxFps() while the window isn't focused or is minimized (new iconify callback); bg_mute sets the master volume to 0 then. Why: an alt-tabbed game running uncapped burns power and heat for nothing; focused play is untouched.
  • Window (#119, #1, #166). The windowed size and position are saved on every move / resize (not while maximized or fullscreen) and restored at launch if the title bar lands on a monitor that's still there; WINDOW SIZE offers 720p up to 4K that fit the monitor. A display change (DISPLAY or WINDOW SIZE) shows KEEP / REVERT with a 10 s count and reverts by itself (not in the UI test, whose clicks would race the count).
  • Dedicated GPU (#113). NvOptimusEnablement and AmdPowerXpressRequestHighPerformance exported from the Windows game, so hybrid laptops use the dedicated GPU; VIDEO > MORE shows GL_RENDERER and hints when it looks integrated.
  • Shadow quality (#3). Renderer::setShadowRes reallocates the shadow map in place (1024 / 2048 / 4096, clamped to the GPU's limit) and updates both programs' texel size; it used to be cfg-only and read at startup.
  • HUD size (#7). hud_size multiplies every HUD element's own SIZE (each element's k and hudFont). The menus already scale with the window height, so no separate menu scale was added.
  • Crosshair (#13). ch_dot (0 = THICKNESS), ch_opacity, ch_outline_w, ch_outline_a, ch_wstyle_<weapon> (a weapon's own style, -1 = the normal one), and COPY / PASTE CODE: the ch_ lines as one quark-crosshair: line; pasting reads only ch_ keys, so a code can't change anything else.
  • Hitsounds (#14, #152). hit_volume (0-2), hit_pack (-1 = your SOUNDS pack), headshot_sound (a higher ding over the hit on a headshot), kill_sound (was tied to HITSOUND).
  • Server list (#78). browser_sort, browser_hide_full, browser_hide_empty, browser_max_ping, fav_servers (right-click a row; favourites are listed first and never filtered out), recent_servers (the last 8 joined; RECENT cycles them into the address field). The list shows 5 rows instead of 6 to make room for the filter row.
  • Race reset (owner, 2026-10-30: "keybind R should restart the map/reset to checkpoint in race/surf modes"). New action RACE RESET (R; bind_version 4 leaves it unbound if R was already taken) sends racereset; the server respawns you at your last checkpoint, or with racereset full (no checkpoint yet, or the full restart) at the start with the checkpoint count and splits cleared. Nothing new on the wire: it's a player command and the existing EV_SPAWN. Tap or hold (owner, 2026-10-30: "r tapped = checkpoint, r held for 250ms = full restart"; it was a double press): the checkpoint is sent when R is let go before 250 ms, so a tap acts on release, the earliest moment a tap can be told from a hold. A separate FULL RESTART action (MORE KEYS, unbound) sends racereset full on press.

Settings: batch 2, the 3s (first half)

  • Pages. moreOpen_ is now a page number: GAMEPLAY > MORE (1 page), CONTROLS > MORE (1), VIDEO + AUDIO > MORE (3, the footer button reads MORE 2 / MORE 3). Screen paths /gameplay/more, /video/more2, /video/more3; CONTROLS gets a third keys sub-page, MORE KEYS (/controls/keys2), because the other two key pages have no free rows.
  • Render scale 25-400% (#250). Was 50-200%. The renderer already capped the target at the GPU's largest texture, so 400% of a 4K window is clamped to what the driver allows rather than failing.
  • Fog (#43). fog: a multiple of the old fixed density 0.0035 (0 = none), set in both the world and model programs (Renderer::setFog; the world program keeps it across map changes).
  • Gamma / contrast / saturation (#29). gamma, contrast, saturation, a grade() at the end of the world, model and sky shaders (after tonemapping and gamma encoding), so the HUD and menus aren't touched. At the default (1, 1, 1) it's one uniform compare per pixel and returns unchanged. Why in those shaders: there's no full-screen post pass at render scale 100% with AA off, and adding one would cost a full-screen draw every frame.
  • Video presets (#56). video_preset cycle: LOW (no player shadows, 1024 shadows, no AA, 75% scale, effects 0.6), MEDIUM (shadows 2048, SMOOTH AA), HIGH (4096, MSAA 4x), COMPETITIVE (no shadows, no AA, no fog, effects 0.5, no kill bursts or recoil kick). It sets those settings once; anything changed afterwards stays.
  • Effects (#30, #49). fx_opacity scales every explosion / spark / burst box's alpha (0 = none drawn), kill_bursts, tracers, view_recoil (the gun model's kick; also moves the muzzle point, so shots still leave the gun you see). Muzzle flash and impact sparks were already switches.
  • Colourblind presets (#11). Two more TEAM COLORS presets: RED-GREEN (blue / yellow-orange) and BLUE-YELLOW (cyan / red). colorPairClash simulates protan, deutan and tritan vision (Machado et al. 2009, full severity, linear RGB) and the PLAYER tab warns when the pair's simulated colours are under 0.4 apart (out of 1.73). Checked: cyan / red, blue / orange and both new presets pass; red / green fails for deutans (0.37), white / yellow for tritans (0.29).
  • Enemy model (#15). enemy_model draws every non-friendly player as that model (client only; ranked already forces STANDARD for everyone and is left alone).
  • Sound groups (#33, #174). vol_steps (your footsteps, jumps, landings), vol_others_steps (0-2, everyone else's), vol_weapons (every gunshot sound), vol_objective: the planted bomb now beeps from where it is, in step with the model's blink (every bomb timer / 20 s, at least 0.15 s), reusing the sticky beep a fifth higher. No UI sounds exist yet, so there's no UI volume.
  • Kill feed / chat (#22). feed_time (6 s), feed_lines (6), feed_mine, chat_time (10 s), chat_lines (8), chat_opacity (not while typing). Chat history (#138): typing shows 12 lines of the 30 kept, scrolled by the wheel or Page Up / Down.
  • Team chat (#41). New player command sayteam: in a team mode the server sends the EV_CHAT only to the sender's team, with b = 1 so it shows "(TEAM)"; elsewhere it's an ordinary say. Old clients just show such a line without the tag.
  • Keys (#41, #137, #148, #178). New actions TEAM CHAT, LAST WEAPON (the weapon held before the current one), SPEC NEXT / SPEC PREV (dead or spectating; a click still cycles), HIDE HUD (hide_hud_crosshair keeps the crosshair; the scoreboard still works) - all unbound by default. A bind that takes a key from another action shows "<KEY> WAS ON <ACTION>: UNBOUND THERE" with UNDO for 8 s. The bomb objective line and the F3 hints name your bound key (Game::keyOf). INFO's key list said F4 for VOTE START, which has been F5 since bind version 3. testHeld_ (the UI test's held keys) was 32 bits wide while actions passed 32: now 64, with a static_assert. Ready and inspect have no game system behind them yet, so they're not here.
  • Screen (#28, #60, #176, #25). show_fps / show_ping (top right, frames counted over 0.5 s; hidden while F3 is up), captions (each announcer line as text under the crosshair for 2 s, through one Game::announce), hp_scaled (the health bar against the most health seen this life, so 200-health customs and 50-health ones fill correctly; the client isn't told the mode's max, so its spawn health stands in), ch_head_color (a headshot's crosshair flash).
  • Controls (#18, #73, #5). grapple_toggle (press to hook, press again to let go; reset on death), scroll_wrap / scroll_skip (a bit per weapon; number keys still select them), left_handed (both first-person models mirror sides and their tilt). Dash stays a tap (a toggle makes no sense for an impulse); CROUCH was already off every key page. A separate viewmodel FOV isn't done: the models are drawn in the world pass with the scene's projection, and their SIZE / DEPTH settings already do what a viewmodel FOV is used for.
  • Display (#218, #121, #112). vrr: swap interval 1 and maxFps() capped at refresh - 3 (from the monitor's mode, or DEVICES' REFRESH RATE in fullscreen), the usual G-SYNC / FreeSync recipe that keeps the frame out of vsync's queue. auto_iconify (GLFW_AUTO_ICONIFY). cpu_priority: PRIORITIZE GAME can ask for HIGH_PRIORITY_CLASS instead of ABOVE_NORMAL (setGamePriority(level)).
  • Menus (#161, #165, #193, #77). remember_tab; confirm_leave (QUIT, DISCONNECT, STOP HOSTING arm on the first click as "SURE? ..." and act on a second within 3 s); auto_reconnect (a "connection to server lost" drop goes back to the last address after 2 s, up to 3 tries, not for a kick, a refusal, a demo or your own server) and RECONNECT next to JOIN GAME; queue_flash (a filled queue also plays the finish chime and flashes the taskbar if the game isn't focused).
  • Editor (#195, #196). Redo: each undo step now keeps its own op beside its inverse (edUndoFwd_), so undo moves the step to edRedo_ and Ctrl+Y / Ctrl+Shift+Z does it again; a new edit clears the redo list; drags and coalesced nudges redo to where they ended. Autosave: every 60 s with unsaved changes the map goes to editor-autosave.qmap in the data folder (outside maps/, so it never shows in map lists); SAVE removes it; RESTORE AUTOSAVE appears in the panel when one is left from before.

Settings: batch 2, the 3s (second half)

  • Settings search (#132). tools/settings_index.py reads game.cpp's settings draw functions at build time (a CMake step, like the embedded maps) and writes settings_index.h: each widget label written as a string literal, and the tab / MORE page / keys sub-page it's on, so the index can't go stale when a setting moves. The SEARCH field (top right of the settings panel) matches labels and key action names. While the field has focus, up to 14 matches replace the page body (an overlay over the page would sit on top of live widgets); a click on one opens its page, and a click anywhere else closes the list and goes to the page. Only this field's focus counts (Ui::focused(id)), not typing(), which a slider or colour being typed into also sets.
  • Third bind slot (#140). kBindSlots = 3. Binds are saved as three values per action; older games read the first two and ignore the third, so a config still works if you go back a version. Key rows are now three 82 px slots.
  • Console binds and aliases (#141, #142). bind <key> <command> (cbind_<code> in the config), unbind, binds, alias <name> <commands> (;-separated, alias_<name>), unalias, exec <file> (from the data folder) and toggle <cvar>. A command bind runs on key press, after the action binds. autoexec.cfg runs once at start (not in the UI test, which must start from defaults).
  • All settings export / import (#167). EXPORT puts the config as text (quark-settings: then key=value lines, keys included, your name / Discord / servers left out) on the clipboard; IMPORT applies every line it recognises and says how many. RESET ALL arms on the first click (SURE?) and keeps name, Discord name and server lists.
  • Demos (#143, #144, #145). The DEMOS page sorts by date / name / size / map (demo_sort), shows sizes, and DELETE asks first. auto_record: OFF / EVERY MATCH / RANKED, started on entering the match. demo_keep (0 = all) deletes the oldest demos past N when an auto recording starts (never the one being written).
  • Sensitivity converter (#9). Quark's yaw is 0.022 degrees per count x sensitivity, the same as Source and Quake, so CS2 / Quake / Apex / TF2 carry over 1:1; Valorant is 0.07 and Overwatch 2 / Call of Duty 0.0066. USE <value> sets yours.
  • Fullscreen resolution (#113). fs_width / fs_height from glfwGetVideoModes (0 = the desktop mode). Applied when fullscreen is (re)entered.
  • Practice (#148). Admin command practice savepos|loadpos|god|passive|reset on your own server, refused in ranked or when another human is connected (so it can't be used against anyone). loadpos sets position, velocity and view and sends EV_SPAWN so prediction snaps there instead of smoothing; passive strips the bots' fire buttons; god makes canHurt false for you.
  • Players menu (#156). Pause > HOST > PLAYERS: everyone on your server with KICK (not yourself), plus ADD BOT, KICK BOTS and RESTART, through the existing admin commands.
  • Race ghost (#179). Your position every 50 ms on race maps; a finished run faster than the saved one is written to ghosts/<map>.txt and drawn as a see-through player along the run, by the time since your start. Capped at 20 minutes of points. Client only.
  • Team panel (#17). team_panel: teammates' names and health bars on the left edge in team modes, and a square per enemy, lit while they're alive (off by default).
  • Match summary (#180). match_summary: your shots, hits, accuracy, damage dealt and best streak for the match under the end-of-match scoreboard, counted on the client.
  • Demo rename (#139). RENAME beside DELETE turns the row into a field; Enter renames the file (letters, digits and - kept, the rest _), Esc or a click away leaves it. Not in this batch (still 3s): a turning 3D model preview (#128) and moving the objective / notice / scoreboard boxes (#21), which come with batch 3. Viewmodel FOV: see Controls above.

Thin surfaces are wallbang (owner, 2026-10-30: "any 1/8m surface should have the wallbang property on it")

  • wallbangBrush(b) (map.h): the wallbang material, or any shot-stopping brush whose thinnest side is 1/8 m or less (0.126 with a rounding margin), whatever its material: a Coat (K) layer, a thin panel, trim. raycastBullet (server hit checks and every client prediction of them) and the map's hasWallbang flag use it, so a map with only thin brushes still takes the wallbang path. Movement, sight, splash and grapple rays are unchanged: a thin wall still blocks players and explosions. A bullet passes one or more wallbang brushes and takes the weapon's wallbang damage once, as before. Test: testWallbang (a 1/8 m box passes, a 1/4 m one stops).

Map picker (owner, 2026-10-30: "you shouldnt get to pick maps that are not under the playable gamemode for the map, also map previews should exist in the picker in game; list should be scrollable and 7 wide")

  • Only the mode's maps. The host, vs bots and in-game pickers list a map only if its modes include the chosen mode (a tourney rotation list: tourney; ranked: maps ranked for that ladder). The "NOT FOR THIS MODE" tag, its legend and the ranked-but-greyed cards are gone with them. A console map command can still start any map in any mode.
  • Previews. Each map gets a 192x108 picture from above, made on the CPU when its info is read (once per map file, cached with its title and modes; about a millisecond): each column the colour of its highest surface by material and the map's palette, lit by height and a light from the top left, darker where the height steps. An indoor map's roof and any slab over most of the map are left out so the rooms show; clip and far-scenery brushes too. All previews go to the GPU as one atlas, only when the map list or a file changes, and draw through the swatch image program over the 2D batch (Renderer::preview; the swatch code now shares drawImageQuads). Why not a 3D render: the renderer holds one world (the menu backdrop or the match), so rendering 25 other maps would mean loading each one.
  • 7 wide, scrolling. Cards are 122 px wide (picture over the name, the mode it's for under it); as many rows as fit the band, the wheel scrolls the rest, and it opens scrolled to the current map. Clicking the picture picks the map too.

Settings: batch 3, the 2s (video, visuals, audio, performance, latency, network, crosshair, accessibility)

Everything lives on VIDEO + AUDIO > MORE 2 (added to), MORE 4, MORE 5 and MORE 6 (new pages), the main VIDEO + AUDIO page and CROSSHAIR. Every renderer switch is a shader uniform that is 0 at its default (so the swatch program, which never gets them, draws as before) and is only set when it changes (Renderer::setLook compares); Game::applyLive pushes the batch into the renderer, mixer and connection once a frame as compares and atomic stores. Nothing costs anything per frame at its default.

  • Tone curve (#44). tone_curve: off swaps the ACES-style filmic curve for a straight clamp (world, sky). The dynamic shader never had the curve, so players are unchanged.
  • Shadows (#45, #213). shadow_filter: HARD (1 tap), SOFT (the 4 taps as before), SOFTER (16 taps, a texel apart: inside the caster rect's 2 texel margin, so no rect change). shadow_dist: the shadow map's half-width, 16 / 32 (as before) / 64 m: wider reaches farther but each texel covers more (blurrier). shadow_world / shadow_players: where dynamic shadows land (the world's / dynamic shader's uShadowOn); with neither on, the shadow pass isn't drawn at all. own_shadow off skips your first-person body entirely (it is drawn only as a shadow caster), so no shadow-pass geometry either.
  • Clean surfaces (#46). clean_surfaces drops the palette materials' 2 m panel seams and per-panel shade noise (materials below 12). The newer textured materials are their look, so they stay.
  • Lighting (#47). flat_light: the world shader ignores the lightmap (full sun and sky everywhere, as the see-through pass already does). The bake resolution and bounce count were not made settings: a bake is keyed and cached by map content and the official maps ship baked; another quality would bake every map again (minutes each) and need its own cache key. q's call.
  • Sky and far scenery (#48). simple_sky: the sky shader returns the flat sky colour (no sun glare, no stars). far_scenery off skips the far-scenery chunks (they were already their own chunks; WorldChunk::far marks them).
  • Draw distance (#209). draw_dist 150 / 250 / 400 m is the projection's far plane (was a fixed 400). The frustum's far plane already culls world chunks and dynamic objects, so a shorter distance also draws less.
  • Player brightness (#50) and always lit (#210). player_emit: the dynamic shader's own-light share (0.3 as before; 1 = full colour in any shade). players_lit: players get sun visibility 1 and the per-player sun ray (the costliest thing per player) is skipped.
  • MSAA 8x (#53). The cycle offers 8x; msaa=8 loads. The renderer already clamped to GL_MAX_SAMPLES.
  • Show when not supported (#219). MSAA levels above the GPU's maximum are labelled N/A with the maximum; 10-BIT OUTPUT says what the window got; BYPASS COMPOSITOR says Linux only elsewhere; the latent sync settings say Windows only, or to pick latent sync first; AUDIO LATENCY says LOW = NORMAL on Windows.
  • FOV mode (#74). fov_mode: FOV read as horizontal on this screen (as before), vertical, or horizontal at 4:3 (Hor+). Game::hfov() turns it into the horizontal degrees every camera / zoom / sensitivity use takes (at most 170).
  • Outlines (#86, #211, #212). A ninth outline preset, CUSTOM, takes outline_friendly_color / outline_enemy_color (MORE 4's pickers; the PLAYER tab's cycles list it automatically through kOutlineColors). outline_alpha mixes the outline toward the body colour (a uniform in the dynamic shader). outline_team_width / outline_enemy_width (-1 = PLAYER OUTLINE WIDTH). glTF player models have no edge data, so a real outline would need an inverted-hull pass per model (a cost): a hint says so instead.
  • Fixed team colours (#87). fixed_teams with team_a_color / team_b_color: while spectating or playing a demo, team modes draw team A and B in these instead of "yours / the enemy" (which made team A always your friendly colour).
  • Tracers (#101), rope (#103), trails (#215). tracer_mode classic pale yellow / the shooter's team / tracer_color (each tracer carries its colour, so no lookups when drawn). rope_color (was a fixed near-black). trails off / short (40%) / full and trail_opacity for the rocket, bolt, knife, syringe and orb streaks.
  • Colour presets (#111). COPY / PASTE COLORS: every colour setting (team, outlines, crosshair, tracer, rope, fullbright, name) as one quark-colors: line, only those keys read back. Three slots (color_preset_1..3) SAVE and LOAD it.
  • Deband and 10-bit (#204). dither: plus or minus half an 8-bit step of interleaved gradient noise on the world, players and sky (no bands in sky and fog). out10: asks GLFW for a 10-bit window at the next start (falls back to 8 if none); when granted, the scene targets (MSAA / render scale) are RGB10_A2 too so the bits reach the window.
  • Accessibility (#58, #59, #61, #108). it_palette: tag's "it" in your enemy colour and the rest in your team's (was fixed red / blue), and the IT banner too. split_palette / split_words: race splits in your team / enemy colours, and AHEAD / BEHIND after the time. reduce_flash: no muzzle flashes, the bomb and stuck stickies stop blinking, explosions at 40% opacity, the damage flash at most 0.3. safe_colors: the OK / error colours (kGreenOk / kRed, no longer const) become blue and orange, told apart under every common colour blindness.
  • Audio output (#34, #140, #231, #232). channels stereo / mono / swapped, mixed after the voices. stereo_width (the pan factor, 0.8 before), behind_muffle (scales the X8 behind-you dip and low-pass), hear_range (the 90 m cut-off, 30 - 150 m). occlusion: one map ray from your ears as each 3D sound starts; a wall between makes it about 7 dB quieter and low-passed (~1 kHz); off by default because it changes what you hear. dyn_range: CLEAN (the soft limiter at 0.08 instead of 0.3), NORMAL (as before), NIGHT (a 5 ms / 300 ms level follower pulling loud passages down about 4:1 over -20 dB, then +6 dB). HRTF was not done: it needs an HRTF data set and a convolution per voice (a real mixer cost and an asset choice). Sound when unfocused: bg_volume, next to the existing MUTE IN BACKGROUND.
  • Volume (#234). VOLUME goes to 150% (volume up to 1.5, shown in %); vol_db shows every volume as its gain in dB; vol_perceptual makes the gain the slider's square.
  • Audio latency (#116). audio_latency: ALSA 10 / 20 (as before) / 40 ms; CoreAudio 2 / 3 / 6 buffers; WASAPI SAFE keeps twice the queue (LOW is NORMAL there: the owner found anything under one engine period worse). The output is reopened when it changes.
  • Sound pack, callouts and cues (#63, #65, #154, #172, #173). force_pack: everyone's sounds in your pack (Game::soundPackOf). streak_calls all / mine / off. time_warn off / 60 + 30 (as before) / also a count of the last 10 s (ticks, then THREE TWO ONE), time_warn_text as notices too. cue_mute / ann_mute: a bit per sound id; Audio::setMuted drops them at play / play3D with one bit test; muted announcer lines also skip their caption.
  • Visualize sounds (#228). sound_viz: the mixer keeps the 3D sounds started (game thread, only while on) and they're drawn as STEPS / SHOT / BOOM / HOOK / HURT / DASH round the crosshair toward where they came from, fading over 0.8 s.
  • Net graph and packet loss protection (#17, #115). net_graph: ping and loss over 12 s, snapshots in, input packets out and how many commands each carries, and the interpolation delay, at the right edge (F3 unchanged). The command rate itself stays one packet per tick (fewer would add latency). cmd_redundancy 2 / 4 (as before) / 8 commands per input packet (NetClient::setCmdsPerPacket; the server already reads up to 16).
  • Downloaded maps (#194). map_downloads off: a server's map you don't have ends the connection with a message instead of downloading. map_cache_max keeps the newest N in maps/downloads (pruned after each download and when lowered); the page shows the count and size, CLEAR DOWNLOADED MAPS empties it.
  • Light cache (#117). light_cache_max (was a fixed 32 bakes), bake_threads (0 = all but one core, as before), the cache's bakes and size, CLEAR LIGHT BAKES (shipped bakes stay). A prebake of every map was not added: the official maps ship baked, and baking your own maps at once would hold every core for minutes.
  • Latent sync (#114). latent_safety_us: the spare time before the blank (the fixed 100 us before). latent_slow: a frame that misses its blank flips now (a tear, as before) or WAITs for the next one (no tear, a refresh later). Windows only, like latent sync.
  • Bypass compositor (#223). bypass_compositor: Linux on X11, _NET_WM_BYPASS_COMPOSITOR on the window: GLFW's (fullscreen only), ALWAYS (a window unredirected too) or NEVER. The X11 calls are declared by hand so Xlib's macros stay out of game.cpp.
  • Menu cursor (#230). cursor_size system / x1 / x1.5 / x2 and cursor_color white / black / yellow / team: an arrow made with glfwCreateCursor (again only when they change).
  • USE toggle (#149). use_toggle: a press holds USE (plant / defuse) until the next press, like GRAPPLE TOGGLE; dying lets go. On VIDEO + AUDIO > MORE 6 (CONTROLS is another branch's this batch).
  • Crosshair (#145, #217). ch_spread: a ring of dots at the held weapon's spread (its tangent over the view's), shown when it's wider than the crosshair. ch_cooldown: 32 dots round the crosshair that fill as the refire runs out (hidden when ready). ch_pixels: the crosshair's sizes as exact pixels, not scaled from 720p. All three ride in the crosshair code.

Settings: batch 3, the 2s (HUD, social, controls, menus, host, editor, launcher)

Worker 2's half of the 2-scored items. Batch-3 keys are table-driven (b3Keys in config.cpp: type, bounds, default), parsed by setBatch3 before the old keys and written by batch3Text, so each new key is one line and always clamped. Three MORE pages are new (GAMEPLAY > MORE 2 / 3 / 4, CONTROLS > MORE 2, PLAYER > MORE), each with its screen path for the UI test and its page function in tools/settings_index.py so the settings search finds every label. Nothing new runs per frame at the defaults: every feature sits behind its setting's check (a compare), and the three that track state from frame to frame (health, alerts, auto-hide) are a few compares each.

Feedback (GAMEPLAY > MORE 2)

  • Damage numbers (#16). dmg_num_color (a normal hit; headshots stay red and big hits gold so the meaning stays), dmg_num_stack (quick hits add up / a number a hit / one running total a target until it fades) and dmg_num_time (0.3 to 3 s, 0.9 as before).
  • Damage arcs (#23). dmg_arcs, dmg_arc_color, dmg_arc_size (x0.5 to x2) and dmg_arc_time: the arc pointing where a hit you took came from.
  • Kill mark and hit flash (#24). kill_mark, kill_mark_time, kill_mark_color; hit_flash_time (its colours stay on the CROSSHAIR tab, which is the other worker's).
  • Kill text (#153). kill_text: "KILLED <NAME>" over the crosshair for 1.5 s.
  • Low health (#62, #92). low_hp (the share of the mode's max health, so vampire and instagib work too), low_hp_color, low_hp_pulse and low_hp_sound (one beep on crossing the threshold, from trackHealth, never repeated while you stay low).
  • Heal numbers (#177). heal_nums: a green +N over your health when it rises (vampire, regen, the syringe); heals within a second add up.
  • Weapon kick (#55). recoil_amount x0 to x2 multiplies the view model's kick (and the muzzle point that follows it); FOV and hand were #5.
  • Per-weapon model (#144). wpn_vm_<n>=<shown>,<x>,<y>,<depth> (written only when not the default): a weapon's model hidden, or moved on top of the HUD tab's WEAPON MODEL element (same units: 720p pixels and mm, -200 to 200). weaponFrame takes the weapon, so the muzzle (tracers, beams, your projectiles' start) follows the moved model; hiding one weapon's model also hides its muzzle flash and, with YOUR SHOTS: WITH WEAPON MODEL, its tracers, as the whole-model switch does.

HUD info (GAMEPLAY > MORE 3)

  • Speed meter (#69, #200, #199). speed_unit (m/s, the game's own; km/h; ups at 39.37 a metre, as Quake counts), speed_scale (the bar's full scale) and speed_ticks; bar_caps turns each bar's caption on or off.
  • Clock (#69, #158). clock_12h for the verbose TIME line; clock_mode (time left / elapsed) and clock_tenths (tenths in the last 10 s) for the round clock, drawn after the live line so hudclock.h stays as it is.
  • Verbose lines (#27). verbose_lines: a bit per line of the verbose info block.
  • Auto-hide (#159). autohide_health always / after a change (3 s) and when low / only when low; autohide_slots always / after a switch; autohide_bars always / while one is recharging.
  • Objective markers (#68). obj_size, obj_mark_opacity (not obj_opacity, which is the objective zones' opacity on VIDEO + AUDIO > MORE 7), obj_distance (the distance under the label) and obj_arrow (an arrow at the screen edge when the objective is off screen).
  • Scope (#200). scope_size, scope_color, scope_line (the reticle's width).
  • Network warnings (#71). net_icons: STALLED (nothing received for 0.3 s), LOSS (3% snapshot loss or more) and PING (150 ms or more) badges. NetClient::lastRecvTime() was added for the stall; not on your own server or in a demo.
  • Spectator HUD and feed (#202). spec_hud full / minimal; feed_headshot adds (HS) to headshot kills in the feed.
  • Stalemate warning (#175). drain_warn: the round modes' last 10 s before health starts draining, over the crosshair, with a beep at 10 and 5.

Social (GAMEPLAY > MORE 4)

  • Chat (#36, #98, #168). team_chat_only, chat_filter (a built-in word list, matched case-blind at a word's start, the rest of the word starred), chat_one_color + chat_color, sys_color, chat_box + chat_box_opacity (a box behind the chat lines), chat_stamps (the local time before each line), mention_hl (a line naming you in gold) and mention_sound.
  • System messages (#242). sys_msgs all / important (votes, kicks, ranked, errors) / off.
  • Name colours (#70). other_name_colors off draws everyone else's name white.
  • Streamer mode (#79). streamer: server addresses, invite codes and the Discord code shown as stars, other players' names as PLAYER 1, 2..., the data folder path hidden.
  • Scoreboard (#185). score_sort (score, kills, deaths, ping, name; within each team) and score_cols (a bit per column).
  • Alerts (#241). alerts: with the window in the background the taskbar button flashes on a match start, a round start, a vote called or a player joining (glfwRequestWindowAttention).
  • On join (#243). on_join play / spectate / ask, applied once when a match's first snapshot arrives.
  • Quips (#80, #130). Pause > PLAYERS is now open to every player (KICK, bots and restart stay the host's). QUIPS ON / OFF per player writes quip_block (names), and their quips are not played. quip_pick (PLAYER > MORE: MY QUIP) sends quip <n> to the server, which plays that one instead of a random one. No wheel: a picker only.
  • Hide ratings, remember queues (#171). hide_ratings drops the RATING column from the ranked scoreboard; remember_queues saves your ranked queues (last_queues) and queues them again at the next start (not in the UI test). Not done: fill notify / join, Discord presence and showing Discord names (each needs the Discord bot or Worker side).

Spectating and the death camera

  • Death camera (#75, #155). death_cam the killer / my body / a teammate, for kill_cam_time 0 to 3 s (the client default stays 1.5; the server's limit kKillCamTime was raised from 1.5 to 3 so a longer camera isn't cut off).
  • Spectating (#32). spec_fov and spec_third (third person behind the watched player, pulled in by a ray against the map so it never goes through a wall). Free camera already existed. Demo speed and seek are not done: playback runs through the snapshot clock, so a speed needs the interpolation clock scaled with it, and seeking back needs a replay from the start with events muted; more than a setting.

Controls (CONTROLS > MORE 2, binds)

  • Per weapon (#40, #142, #143). wpn_sens_<n> (x0.1 to x5, written only when not 1), alt_toggle_weapons (a bit per weapon; -1 = the old rule, the scopes only) and swap_fire (a bit per weapon: fire and alt-fire swapped while it's held).
  • Mouse (#40). mouse_accel 0 to 1 (faster moves turn further, at most x4) and raw_input (GLFW raw mouse motion where supported; off: the OS's pointer motion).
  • Auto-hop (#146). auto_hop off: holding jump jumps once; let go and press again.
  • Console (#123). console_key ( F1 F10 F11 INSERT HOME \ '; one no action is bound to) and console_menus` (open it from the menus too).
  • Ranked (#150). ranked_alt_toggle: the alt-fire (scope) toggle allowed in ranked; the other control scripts stay off there.
  • New binds (#235, #237). VOL UP / VOL DOWN / MUTE, RECORD (start / stop a demo), JOIN TEAM, SPECTATE, appended to the action list so older binds keep their slots.
  • Mode profiles (#126). A controls profile can now hold sensitivity, fov (taken through FOV MODE like the main FOV), zoom_fov_sniper and zoom_fov_rifle, applied while that mode is played; EXPORT writes them. Crosshair per mode is not done: the crosshair is the other worker's tab.

Menus (PLAYER > MORE, #52, #107, #118, #131)

  • menu_theme dark / high contrast (buttons outlined, brighter text), menu_accent_on + menu_accent (not the team colour), text_bright, panel_dark. No light theme: the menus are drawn over the 3D backdrop and every hint colour assumes a dark panel; it needs a design pass.
  • menu_bg clockwork / a map you pick (menu_bg_map) / a plain colour (menu_bg_color).
  • The data folder's path with OPEN (MOVE not done: moving a running game's data folder needs a restart and a copy step).
  • Mods: use_overrides (your own asset overrides on or off, applied at start), buttons for the overrides and mods folders, and how many overrides you have. No live reload: assets are loaded once at start.

HUD elements and preview (#21, #128, #26)

  • Objective, notices and scoreboard as HUD elements (#21). HUD_OBJECTIVE, HUD_NOTICES, HUD_SCOREBOARD are appended (hud_11 to hud_13), so older configs' hud_<n> keep their meaning, and theme.cfg gets objective, notices and scoreboard. They keep their own drawing code (the stacked notice column and the scoreboard's table); Renderer::uiTransform moves and sizes what they drew, as a group, about their anchor (the objective line's top centre, the notices' top centre, the scoreboard's top centre), plus the theme's margin and opacity. At the defaults nothing runs (one compare). The objective's colour tints its line; NOTICES tints the notices and the round message; the scoreboard keeps its team colours, so it has no colour row. Hidden: the objective line, its bar and the bomb timer go; the scoreboard isn't drawn on its key. Ranked keeps the default HUD. They're on Settings > HUD's ELEMENT list; the on-screen editor doesn't list them yet (it needs each element's box, which a fixed-code draw doesn't give).
  • Model preview (#128). PLAYER shows the chosen model in your team colour under the outlines, turning slowly; drag to turn it. Its boxes (playerModelParts, or a box override) are projected flat into the 2D batch (back faces culled, far faces first, one light): no render target, no extra pass, about 150 quads, and only while the tab is open. A mesh override shows a line instead.
  • Widget values (#26). hud.cfg widgets can show snaprate, accuracy, damage, streak, round, vspeed, height and weapon (appended to the value list). Per-mode widgets already work through the HUD profiles (HUD > PER MODE).

Host and votes

  • Votes (#187). callvote restart, extend (5 more minutes), shuffle (the humans dealt to the teams alternately, then a restart) and kick <player> (the same checks as moving one to spectator; the target can't vote). Text commands and vote text, so no protocol change.
  • Host advanced (#188, #189). host_tick and host_snap (cycles), host_upload (an upload cap), host_auto_start, host_bot_skill (balance bot skill): passed to the server config at host start. Tourney rotation not done: it needs the bracket to carry over between matches on the server.
  • Vs bots (#192). Up to 15 enemies and 15 teammates (was 7 / 7).
  • Not done: a mode rules tab from the param table (#190) and the custom mode editor's extras (#191: weapon set, movement, logic file, duplicate, share code). Both change the custom mode format and the host flow; they need q's call on the share code format.

Editor (#81, #82, #197, #198)

  • ed_fly_binds (fly keys follow your movement binds), ed_fast_mul (was a fixed x3), ed_fly_speed and ed_grid remembered, ed_grid_custom (a grid step after the built-in ones, reached with [ and ]), ed_rot_step (Q / E on a spawn; 45 as before), ed_light (new lights take the last light's values). Saved when the editor closes or test play starts, never from the widgets themselves.
  • Test play: ed_test_mode (the map's or any mode), ed_test_bots (0 to 7), ed_test_at_cam (practice setpos, host alone, clamped to the map's range), ed_test_no_warmup.

Race (#182, #183)

  • race_split_vs best run / best segments / last run and race_split_time; the race stats page's map picker and race_date format. No split against #1: the leaderboard has times, not splits.

Launcher (#83, #203)

  • OPTIONS (in place of the changelog panel; the bottom row is now UNINSTALL | CHANNEL | OPTIONS): auto_start (start the game when it's up to date), keep_open (PLAY doesn't close the launcher), args (launch options, passed to the game on every platform) and updates automatic / ask first / at next launch (an update is held back, and the next launch installs it; never for a first install or a pinned version). Asset names unchanged. Install location picker (#84) not done: it needs native folder dialogs on three platforms and moving an install.

Skipped, with why

  • #76 pause offline games when unfocused: the server's pause resets the match state and the snapshot stream; needs a server-side pause first.
  • #133 practice speed: needs a timescale through prediction and the server tick.
  • #170 UTF-8 chat: the font atlas is 8-bit and the server sanitises to ASCII.
  • #184 leaderboard paging / around me: needs new Worker API endpoints.
  • #238 instant replay buffer: needs an always-on in-memory recorder; left for a performance review.
  • #127 player accent colour: other players would need it, which is a protocol change.
  • #162 right-click reset on every control: sliders and colour pickers already do; toggles and cycles have no default argument, so it means changing every call site.
  • #164 keyboard menu navigation: needs a focus order for every widget on every page.

Settings: batch 4, the 1s (video, visuals, audio, latency, accessibility, cosmetics, modes, movement)

Everything is on VIDEO + AUDIO > MORE 2 (added to) and two new pages, MORE 7 (colours) and MORE 8 (screenshots, demos, the window, latency, sound). As in batch 3, every renderer switch is a uniform at its old value by default and set only when it changes, and Game::applyLive pushes the lot once a frame as compares and stores; nothing costs anything per frame at its default.

  • Liquids and glow (#51). liquid_flow off gives the world shader uTime 0, so water and lava stand still (no animation, same cost). glow_strength 0 to 2 scales the map's glow colour (1 as before).
  • World outline colour (#57). world_edge_color: the world shader's panel outline now mixes towards uWorldEdge (black by default, which is what it was) instead of a constant.
  • Typing sounds (#64). TYPING SOUNDS flips the SND_TYPING bit in batch 3's cue_mute, the same bit as its CUE row.
  • Hitsound tiers (#66). hit_tiers off plays the medium hitsound for every hit, whatever the damage.
  • FFA colours (#88). ffa_colors: in modes without teams each other player gets one of 8 distinct colours by id (teamColor), so you can tell opponents apart; off as before (all enemy colour).
  • Own effects colour (#89). own_fx / own_fx_color: your projectiles, laser and rope in your colour (the rope lambda now knows whose rope it draws).
  • Health bars (#90). hb_low (0 to 100 HP, 30 as before) is where bars and the HUD health number turn to hb_low_color (the old red until you pick one); hb_custom / hb_color set the healthy bar's colour.
  • Name tags (#91). tag_color team / white / custom (tag_custom), tag_alpha 0.1 to 1, tag_size 0.5x to 3x.
  • Effect colour (#102). fx_color_mode: explosions in their own colour (as before), your team's (explosions don't record an owner, so "team" is your team's colour) or fx_color. Kill bursts keep theirs: they say whose kill it was.
  • Objectives in your palette (#104). obj_palette: the bomb's blink and payload checkpoints not yet reached use your enemy colour (from your colour scheme, so the batch 3 palettes apply). The other objectives were already team-coloured.
  • Map colour overrides, high contrast (#105). sky_override / sky_color and glow_override / glow_color replace every map's sky and glow (prepareScene builds them as locals; x < 0 means the map's own). high_contrast lightens wall base colours and darkens trim.
  • F3 colourblind palette (#109). f3_safe: the F3 pipeline bars use the Okabe-Ito colours (blue / orange / bluish green / vermilion ...), distinct in every common colour blindness.
  • Always on top (#122). on_top: GLFW_FLOATING, set when it changes. Confine cursor was skipped: GLFW 3.3 has no confine, in play the cursor is already captured, and the menus would need per-OS code that can't be tested here.
  • Gun colour (#129). gun_color map (as before) / team / custom (gun_custom): the viewmodel's metal tint.
  • Objective zones (#136). obj_opacity 0 to 2 scales points zones' and the KOTH hill's fill; obj_pulse off holds them steady.
  • Dash buffer (#147). dash_buffer off / 0.05 / 0.1 (as before) / 0.15 s: how long a dash pressed before it's ready is kept.
  • Smooth strength (#206). smooth_amt 0.25 to 1: the FXAA pass mixes its result with the unfiltered pixel (uEdgeOff, 1 = full as before), set only when it changes.
  • Lightmap filter (#208). lightmap_nearest: the lightmap texture's filter becomes GL_NEAREST (crisp texel blocks, the old-school look), switched only when it changes.
  • Wallbang tint (#214). wallbang_tint / wallbang_color: wallbang brushes in your colour instead of the dark grey.
  • Ultra safety margin (#220). ultra_margin_us 0 to 2000 µs (150 as before; the slider goes to 1000): how early low latency ULTRA starts each frame. More margin, fewer missed vblanks; less, a little lower latency.
  • NVIDIA driver profile (#222). nv_threaded (Threaded Optimization off, setting 0x20C1221E) and nv_dxgi (Vulkan/OpenGL present method: DXGI, 0x20D690F8) write the game's NVIDIA profile through NVAPI, the same path as GPU MAX CLOCKS (now a shared nvProfileSetting). Windows only; the setting IDs are from NvApiDriverSettings.h and could not be tested on an NVIDIA card here. A failure says why and turns the toggle back off.
  • Quip captions (#229). quip_captions: player quips get a caption even with CAPTIONS off.
  • Announcer ducking (#233). ann_duck 0 (off, as before) to 1: effects get that much quieter while an announcer line plays (the mixer checks its voices once a buffer).
  • Demo rate in settings (#20). DEMO RATE (demo_full_rate, 62.5 Hz or every snapshot) is on MORE 8 as well as on DEMOS.
  • Screenshots (#38). shot_format PNG / TGA, shot_folder the game's data folder / Pictures/Quark in your home folder, shot_hide_hud saves the 3D view right after it's drawn, before the HUD (one frame later, no extra pass). OPEN FOLDER opens it. JPG was not added: the tree has no JPG encoder and adding one is a new third-party file.
  • Skipped: #19 voice chat (there is none), #85 language (every string would first have to move into one table), #224 HDR (parked).

Settings: batch 4, the 1s (HUD, menus, social, editor, practice, gameplay)

Worker 2's share of the 1-scored items, in the same batch-3 key table (b3Keys). Nothing runs per frame at the defaults beyond what ran before: the colours replace constants, the compass and radar draw only when on.

  • HUD > MORE (new page; morePages / moreNav / search / uiScreenPath / settings_index know it). The HUD tab's panel ends at Y 560 to leave the live HUD preview visible, so the HUD's MORE button sits in that footer; the MORE page itself is a normal full-height page without the preview.
  • Colours (#93, #94, #95, #96, #99). bar_recharge_color (a bar while it recharges, and the dash pips), speed_low_color / speed_high_color (the speed meter blends from one to the other as before from gold to red), slot_off_color (the weapon list: a weapon not usable now, at its old 35% opacity), sticky_full_color, warn_color (the ranked and unrated notes under the score box), info_color (the grey lines: JOIN A TEAM..., the host's port and listing lines), dmg_head_color / dmg_big_color (damage numbers, 40+ for big). Each defaults to the colour it replaces.
  • Text outline (#216). text_outline 0 to x1.5 scales the UI shader's outline band (a uniform set only when it changes). Capped at 1.5 and kept at least 0.12 distance-field units inside the glyph edge, because the distance field's spread runs out at small text and a thicker band blotted the letters together.
  • Notices (#72). notice_lines 1 to 8 (was a fixed 4) and notice_time x0.5 to x3 on each notice's time.
  • Compass and radar (#160). compass: a 180-degree strip at the bottom centre with N / NE / E... every 45 and ticks every 15. radar: a 60 px disc top left, up = where you look, radar_range 10 to 120 m (further ones sit on the rim): your teammates in team modes and the objective markers' positions (collected from the marker labels the world pass already draws). Never enemies, and off in ranked (the default HUD). No minimap: it needs a top-down render of each map.
  • REC (#201). hide_rec drops the blinking REC while recording; the demo playback bar stays (it's the playback control's progress).
  • Tourney TAB (#179). tourney_tab 0 the bracket (as before), 1 the scoreboard; the bracket still shows between rounds.
  • HUD panel colour (#97). Not a new setting: theme.cfg's hud-panel (background, border, opacity) already does it, and the on-screen editor edits it.
  • Stair smoothing (#156). step_smooth off skips the X7 camera ease on stair steps (the view jumps with your feet).
  • Typing indicator (#169). send_typing off: the client stops sending typing while you type, so nobody sees your "..." or hears your keys.
  • Clickable tiebreak (#180). rps_click: while you choose in the hide and seek tiebreak, the cursor is freed and a click on ROCK / PAPER / SCISSORS throws it (the slot keys still work).
  • Fixed logo (#110). fixed_logo: the title in the default cyan / red instead of your team colours.
  • Bots (#135). bot_looks 0 a mix of models (as before), 1 all STANDARD; bots/names.txt (BOT NAMES FILE makes one and opens the folder): one name a line, letters / digits / spaces / - _, up to 12 characters and 64 names, given to your server's bots as "BOT <NAME>" (ServerConfig botNames, botLooks; text in the config, no protocol change). Hosted games only; dedicated servers keep the built-in names.
  • Clip box colour (#106). clip_color for the editor's see-through clip boxes (their opacity unchanged).
  • Race / leaderboard colours (#100). Worker 1's SPLITS: PALETTE (split_palette) now also colours the race stats' BEST row and the leaderboard's #1 in your team colour instead of gold, rather than a second setting for the same thing.

Movement: wall teleporters keep speed, clip refuses the grapple (owner, 2026-10-30: "clip should also refuse hook"; the teleporter bug from a map review)

  • Wall teleporters kept only the clipped speed. A teleporter block is solid, and the touch check (pmove.cpp) ran after the slide move had already clipped the velocity against it. So whenever a step ended inside the wall rather than just short of it, n * length(s.vel) used what was left after the clip. At 60 m/s into a wall teleporter, 4 starts out of 8 came out at under 1 m/s. Now the exit speed is the larger of the speeds before and after the slide. Floor teleporters behaved the same before; it only showed on walls, because the speed was horizontal. Test: movement, 8 start offsets at 60 m/s, all keep 60.
  • Clip refuses the grapple. Clip (material 9) let the hook pass through it to whatever was behind, which made the invisible lids and the clip over walls hook points nobody could see. Map::raycast takes hitClip (only the grapple and the bots' hook planning pass it). A hook that reaches clip hits it and lets go, like nograpple. Shots and sight still pass clip. Cost: none on the shot path (the extra test only runs on brushes that don't stop shots).

Maps: q's Surfy and Cobblestone ship (owner, 2026-10-30: "make sure all future updates get cobblestone map and surfy map, use latest version; also surfy map is default surf gamemode map")

  • Shipped as hand-made maps. maps/surfy.qmap (58 brushes, for surf, saved 2026-10-29 18:46) and maps/cobblestone.qmap (885 brushes, no for line, saved 2026-10-29 15:59) are the newest copies from q's maps folder, byte for byte (sha256 20620ee2... and 31fbb542...). .gitignore un-ignores both, so CMake embeds and copies them into every build like the generated maps. As handmade maps they run every map check but never fail quark_tests.
  • Surfy is surf's default. Game::bestMapFor took the first map built for a mode, alphabetically, which is Cascade. A small per-mode default (defaultMapName: surf -> surfy) now comes first when that map is there and offers the mode. The mode picker's "MAP:" line now names the map bestMapFor would start on, instead of the last map built for the mode, so the line and the start agree (it also fixes koth and hide naming a different map than the one you got).
  • Race and surf on a map with no race spawn. Surfy starts players on an any spawn, but race and surf asked for a race one, so the game didn't count Surfy as a surf map at all and would have kept Cascade as the default. Now a map with race checkpoints and no race spawn offers race and surf, and modeSpawnType starts racers on its any spawns. Maps with race spawns behave as before.
  • Hand-made maps don't fail the test run, as CLAUDE.md says: the spawn-pool check (Surfy has 1 spawn for 8 racers, Cobblestone 4 for 8 in FFA) and the save round trip's float-noise check (Quarkology's cut pieces write numbers like 1.0000048) now report a hand-made map's misses as HUMAN-MADE, like the other map checks.
  • Why Cobblestone ships: it's q's test map for the cobblestone look's frame-time lows (0.1% low 800 vs 2800 fps on the older looks, parked to fix later), so every build carries the same benchmark.

Servers: testing-only install (owner, 2026-10-30: "default is both, you should have the option")

  • install.sh --testing-only sets up only the testing instance (user quark-testing, /opt/quark-testing, /var/lib/quark-testing) on the production port (--port, default 27500; --testing-port overrides). No quark user or production clone is made; an existing production server is stopped and disabled with its data kept. The testing instance builds this checkout itself (there's no production build to copy) and runs its first update check during the install, as production does.
  • Always on. Its update unit sets QUARK_TESTING_ALWAYS=1, and autoupdate.sh then skips the "only while newer than production" switch: no active file, no quark-testing-active.path, no ExecCondition. It runs the newest testing build, and installs and runs the production release once that is newer (the updater already built production in that case to keep its own code current; now the server also runs it), so the box never sits empty after a release. Why: a box meant only for testing would otherwise be idle from each release until the next testing push.
  • No governor, normal priority when alone: the load governor and Nice=5 / CPUWeight=50 exist to make room for production, which isn't there.
  • Remembered in /etc/quark/install.conf (PRODUCTION=0), so re-running install.sh with no options keeps it, taking the server options from the testing unit. --both goes back to both (the testing port then moves to production + 1 again unless --testing-port says otherwise); --no-testing still means production only. The default is unchanged.

Servers: the OFFICIAL tag (owner, 2026-10-30: official servers get a tag in the server browser that can't be faked by naming a server "official")

  • What. JOIN's server list draws an OFFICIAL chip (the ranked gold: gold Rubik text at hint size on a dark gold chip with a gold border; dark text was tried and the text outline swallowed it) before the name of an official server and sorts the official servers first, ahead of favourites and the chosen sort. The official servers are now one box, 75.127.7.139: production on UDP 27500, testing on 27501.
  • How it can't be faked. The client has the list built in (kOfficialServers, src/game/master.cpp; isOfficialServer(ip, port) / isOfficialServer("a.b.c.d:port"), master.h) and matches it against the address the info reply actually came from (ServerInfo::official, set once per reply in ServerBrowser::poll, and for a silent internet entry from its listed address), never against the name the server reports. A spoofed UDP source would also need the reply to reach the browser's own socket for a query it sent, and joining goes to that same address. The string form is strict (four dotted parts, no leading zeros, a port required), so lookalikes such as 75.127.7.139:27502, 75.127.7.13:27500 or 075.127.7.139:27500 don't match (testOfficialServers, group ranked).
  • Why built in, not from the Worker. /list's format stays one ip:port per line (old clients take any short line as an address), and a flag served by the Worker would only be as trustworthy as the Worker's reply; a list in the signed game can only change with a signed release. The cost: if the official servers move, kOfficialServers must be updated and a release shipped (docs/HANDOVER.md says so).
  • Cost. One bool per server, computed when its reply arrives; the draw adds a static string, a measured width, one rounded rect and one text per official row: no allocation a frame.

Modes: power (MODE_POWER 14; owner, 2026-10-30: "each team has player count * 2500 health, nobody can die, just soak damage; when total team health reaches 0 they lose")

  • Rules. Team vs with one health pool per team: poolPerPlayer (2500, a new param of the new pool object) x the players on it, full at the start of each round. In Server::damagePlayer, an enemy's hit (after the ruleset's multipliers, so instagib takes 101) comes off the victim's team pool while the round is live, and never off the player's own health: nobody dies of damage, and knockback lands as before. Your own hits, teammates' and the world's (acid, spike floors and the other liquid / feature damage through takeAcid) take nothing, so a rocket jump or a lava touch can't lose the match for your team; a fall out of the map still kills and respawns you after 2 s (modeRespawnDelay) at no cost to the pool. A pool at 0 loses the round. When the 600 s clock runs out, the pool with the larger share of its size wins (a draw starts another round). One round wins the match (rounds 1).
  • Joins and leaves (poolStep, every tick): a player who joins a team mid-round adds poolPerPlayer to its pool and its size; one who leaves takes their share off the size, and the pool shrinks to fit it (an emptied team's pool goes to 0 and it loses).
  • Wire and demos. The pools ride in ModeState fields power doesn't otherwise use (powerPool / powerPoolMax, protocol.h: payloadDist / payloadLen = the pools, point / pointCount = the players each counts, target = hp per player), so the snapshot format and every demo stay as they were. Protocol 54 anyway: an older client has no mode 14 (demos convert with no change).
  • Mode logic. tools/modes/power.qlogic (use pool, poolStep(), the pool[team] (rw) and poolMax[team] arrays) says what the native rules do; the lockstep test plays power on Bastion natively and through the interpreter at 1000 / 250 / 125 Hz and finds no difference. Custom modes can base on power.
  • HUD. The score box shows your pool and the enemy's (rounded up) instead of rounds; the objective line reads "TEAM POOL n / size - ENEMY n / size" with your pool's share as its bar. Main menu > INFO > MODES explains it. The mode pickers suggest Bastion (power plays on the team vs map).
  • Maps. Bastion and Orbital (team vs) and Lodestone also offer power (also in their builder files), so they're graded for it like their other modes.
  • Bots play it as team vs (no objective; they fight).
  • Tests. testPower (rules): the pools start at 2500 each; an 80 hit takes 80 from the victim's pool and none of their health; your own rocket takes nothing; a third player brings 2500 to their team; emptying team B's pool gives team A the round. The every-mode bot run plays power on Bastion; the lockstep covers it.

Map checks: the walk grid walks ramps (engine work list, 2026-10-30: "the walk check can't see ramps")

  • What was wrong. tests/walkgrid.h (the collision-checked walk behind the spawn / route checks, quark_tests maps, bounds and quark_maptool's check / route / metrics) put every solid brush into its columns as its whole box, box.mn.y .. box.mx.y. A wedge, corner, tetra or cut slope was a block as tall as its high end, so a 15 m decline was a 15 m wall. Mine's haul road (a 25 degree wedge, 15 m over 31 m) was found only by an 18.6 s detour, and Mine's lava ramp and QK7's helix (a solid wedge, then cut ramps every 5 m) weren't found at all.
  • Now. A slope you can walk (WalkGrid::walkableSlope: standing on the floor, up 0, or a cut box, and no steeper than pmove's floorFace limit, n.y >= 0.7071 - 1e-4, tan 1.0002) goes into each column as what of it is over that column's hull footprint (columnSpan): a wedge / corner / tetra up to brushTopOver of the footprint (where pmove's box collision rests the feet: the slope's highest point under the hull); a cut box between its planes' bounds over the footprint's corners (the top no higher than each up-facing plane's highest corner, the bottom no lower than each down-facing plane's lowest; a side plane that cuts the whole footprint away leaves the column out). That is exact on a ramp's face and errs high (more solid, never a false way through) only where two planes meet over the column. A 45 degree ramp rises 0.25 m per 0.25 m column, well inside the 0.55 m step. Steeper slopes, and slopes on their side or hung from a ceiling, stay whole boxes: stepping up a 63 degree face column by column would climb walls pmove slides you off.
  • Standing on a ramp. The feet rest on the slope's highest point under the hull, at its edge, so the 10 cm support check (floor within 0.3 m of the centre, 2 cm under the feet) failed on a slope. Each merged span now carries a slack, (0.4 - 0.3) x the steepness + 5 mm, and the support probe goes that much deeper there (0 on boxes: flat floors check exactly as before).
  • Results. Routes (quark_maptool route, before -> after): Mine haul road foot to top 18.6 s (round the long way) -> 3.7 s; Mine lava ramp unreachable -> 3.3 s; QK7 ground to the first helix landing (5 m) unreachable -> 7.0 s, to the top (30 m) unreachable -> 21.1 s. Standing spots: Mine 535,394 -> 543,740, QK7 264,086 -> 303,058, Datacenter 1,609,545 -> 1,626,575. The shipped maps are unchanged except Cobblestone (151,829 -> 152,465; its slopes), and all still pass.
  • Speed. Only slope brushes take the new path (boxes are rasterised as before). Raster lap on Datacenter (166 cut boxes) about 120 -> 165 ms and Mine 100 -> 133 ms (best of 4, threaded, a shared CPU); the whole build is unchanged within noise (Datacenter 956 -> 932 ms for the header's single-threaded build: fewer spots fail the off-centre search). A first version clipped each cut's faces per column (cutBounds) and took the raster to 670 ms; the corner bounds give the same spots.
  • The maptool copy. tools/maptool/map/walkfast.cpp (the threaded copy of the build) uses the same walkableSlope / columnSpan / mergeSpan / RawSpan from the header; walkverify still finds the two builds identical on Mine, QK7 and Datacenter.
  • Test. testWalkGrid (maps group): a 4 m deck with no way up is unreachable; a 27 degree wedge, a 45 degree wedge, a cut ramp (y <= x over its box), a wedge rising along z and a corner ramp each make it reachable (and the way down walks); a 63 degree wedge and an upside-down wedge don't.

Bots: bomb attackers take every site in turn (engine work list, 2026-10-30: "bomb bots always pick the nearest site")

  • What was wrong. The objective (bots.cpp and the default brain's objective bomb slot) sent the attackers to site(0) every round: the carrier always went for the first site in the file, and the others escorted it. On Datacenter, B is about 8 m further from the attackers, and no bot ever went there.
  • Now. The carrier (and any attacker before someone has the bomb) goes for site (round + teamRank) % sites: deterministic, no randomness, so a seeded match plays the same, and with two sites each one gets every other round (more when the carrier changes rank). The escorts follow the carrier as before. The defenders are still dealt one site each by rank (teamRank % sites), so both sites are covered whenever a side has two or more.
  • Brain language. A new objective sensor roundNo (int: the round, 1 = the match's first, 0 before one), in GRAMMAR.md. The default brain's line is goto site(attacker ? roundNo + teamRank : teamRank), which stays the native code's twin (the brain lockstep tests compare them). Quarkology's bot editor (its ast.js sensor list and fixture default.qbrain) needs roundNo added to know the new sensor.
  • Measured. Depot, 4 v 4 expert bots, 600 s (the new bombsites test group): before, 16 rounds with plants A 7, B 0; now 20 rounds, A 10, B 8, defenders sent 40 / 40. Datacenter (maps-m2's map, quark_maptool bots, 8 experts, 600 s): 12 plants, A 5, B 7.

Map format 11: mode spawn sets (q, 2026-10-30: Mine's koth hill gets its own koth spawn set, separate from the bomb spawns)

  • Before. A spawn's team was a, b, any, race or a multi point stage (att0..def9), shared by every mode that uses that side. A map built for bomb with a hill too (Mine) could only start koth on its bomb sides.
  • Syntax. spawn <mode>-<side> x y z [yaw], side a, b or any, the mode as modeName writes it (lower case): spawn koth-a -10 0 10 0, spawn koth-b 10 0 -10 180, spawn ffa-any .... One word, so every tool that reads a spawn's coordinates as words 2-4 (tools/maps/prefabs.py, Quarkology's readers) keeps working; M.spawn("koth-a", x, y, z, yaw, mirror_team="koth-b") writes a pair. Refused: race and multi point (race-a, points-a: their race and staged spawns are their sets), an unknown mode or side, upper case.
  • Format change, by the rule. An older reader refuses an unknown spawn team (since format 2), so this is a format change: kMapFormatVersion 10 -> 11, upgradeMap10to11 in kMapUpgrades (nothing to change: a format 10 map has no such spawns; only the version line moves), testModeSpawnSets next to testMapConverter (whose every-version loop now covers 10 -> 11). tools/maps/prefabs.py writes version 11.
  • In memory. The team is SPAWN_MODESET + 3 x mode + side (24 .. 68, after the staged ones, map.h: modeSpawn, isModeSpawn, spawnSetMode, spawnSide); isStagedSpawn now stops at 23. spawnTeamWord / spawnTeamFromWord (map.cpp) are the one reader and writer (the parser, serialize, the maptool's JSON names). Editor edit ops (s set ...) accept them; a race / points set is refused there too.
  • Who uses them. modeSpawnType returns the mode's own set's side when the map has a spawn of it (then spawnPool picks only those), and the normal side otherwise, so a map with no set plays exactly as before, and one side's set alone leaves the other side on the normal spawns. No other mode ever starts on them: spawnPool's last fallback (every spawn) leaves mode sets out unless they are all a map has. canSupportMode counts a mode's own set for its sides (a koth-only map with koth-a / koth-b offers koth, not vs).
  • Checks. The walk check (walkRoutesMissing) routes each mode from its own sides (WalkSpawnSets::forMode): koth from the koth set to the hill. The spawn-pool check already asked modeSpawnType and the sight check too (koth sides in sight of each other are flagged); the mirror-twin check pairs koth-a with koth-b; the 1.5 m spacing check counts a mode set as its own group. quark_maptool: check (the same code), metrics (a mode's objective routes from its set) and custom tests (team: "koth-a"). The pool check wants full / 2 = 4 spawns a side in koth, the same as any team mode: a map's koth set needs 4 a side.
  • Surfy and Cobblestone (the shipped hand-made maps) get version 11 in place of version 10, nothing else: what the converter writes. Left at 10 they converted on every load, and the converted text's sha256 no longer matched the embedded one, so isOfficialMap refused them (ranked group, reset built-in maps).
  • Editor. A spawn's panel gets MODE SET (EVERY MODE, or ONLY one of the 13 modes that can have a set); TEAM (M) then cycles that set's A / B / ANY. A mode set's spawn has a white cap over its team colour.
  • Test. testModeSpawnSets (movement group): read and written back, koth starts on koth-a / koth-b and bomb / vs on a / b, pools hold only their own, the fall backs (no set; one side's set), a koth-only map, ffa-any, eight refused words, edit ops, and a koth spawn sealed in a box reported as "can't reach hill 1" with nothing else reported.

Map format 12: walk teleporters (owner, 2026-10-30: "teleporters should have toggle for carry all speed or max at walking velocity, in solar they should all be walking speed")

  • What. A teleporter has a speed mode: CARRY (as before, the default) or WALK. Through a walk teleporter a player comes out of its pair at min(speed in, run speed): the run speed is kGroundSpeed (9.5 m/s) x the custom mode's speed scale, still pointing straight out of the exit face. At 60 m/s in, out at 9.50 (8 start offsets, every one); at 6.1 m/s in, out at 6.1 (never faster than you went in).
  • Which teleporter decides: the one you go INTO (Brush::tpWalk on the entry). A mapper places a teleporter per route, and a pair can then be walk one way and carry the other (a stage's arrival portal in a surf map caps the run you came in with, the way back keeps it). The exit's own flag only matters for whoever enters it.
  • Why the cap is the run speed and not zero. You leave the exit face moving the way it points, as with carry, so a floor exit still lifts you clear and a wall exit still puts you out of the wall; standing still in the doorway would leave you on the exit's edge.
  • Projectiles keep all their speed through either kind (server.cpp, a projectile that hits one): a rocket capped at 9.5 m/s would hang in the air and fall; the toggle is about players' runs.
  • Format change, by the rule. File: teleporter x0 y0 z0 x1 y1 z1 tp <link> <face> walk (the word only when set; slopes and cut boxes the same after their box). readTp refuses any other word there, and an older reader refuses the word, so kMapFormatVersion 11 -> 12, upgradeMap11to12 in kMapUpgrades (nothing to change: a format 11 teleporter carries, as one without the word still does; only the version line moves) and a v11 case in testMapConverter. tools/maps/prefabs.py writes version 12 and gets M.teleporter(x0, y0, z0, x1, y1, z1, link, face, speed="carry"|"walk"). Surfy and Cobblestone get version 12 (as at 11: left behind they would convert on every load and fail the embedded sha256).
  • Protocol 56. Edit ops carry it (b set ... p <link> <face> walk), and pmove reads it on both sides (prediction: an older client would carry the speed the server caps), so kProtocolVersion 55 -> 56. Demos: no record layout changed, an older demo's edits (p <link> <face>) still read as carry; the existing converter covers it.
  • Editor and tools. The teleporter's panel gets SPEED: CARRY / WALK under EXIT FACE. quark_maptool parse / serialize carry it as speed: "walk" on the brush (left out for carry), so Quarkology keeps it through a save. Bots don't plan through teleporters, nothing to change there.
  • Test. movement (testFeatureBlocks): the 60 m/s wall-teleporter run through a walk one, 8 offsets, all out at 9.50 pointing out of the face; a 4 m/s run in comes out no faster than it went in; written back and read, in an edit op and applied, an unknown word refused. The carry run still keeps 60 at all 8.

Weapons: the primary rocket thrusts 16 m/s2 (owner, 2026-10-30: "rocket starting velocity 48, accel 16m/s2 so after 2 seconds it should be travelling 80m/s")

  • What. kRocketAccel 4 -> 16 m/s2, launch speed 48 m/s as before: 64 m/s after 1 s (56 m covered), 80 m/s after 2 s (128 m), 128 m/s at the end of its 5 s life (440 m in all, was 290 m). Time to 10 / 20 / 40 m: 0.201 / 0.391 / 0.742 s (were 0.207 / 0.410 / 0.806 at 4 m/s2): close up it is the same rocket, and a long shot gets there much sooner.
  • Earlier the same day it was 24 m/s and 8 m/s2 for a few hours (PR #135, protocol 57; the lob held at 33.6 m/s by kRocketLobSpeed 1.4). Both are back: 48 m/s and kRocketLobSpeed 0.7, so the lob is still 33.6 m/s.
  • A whole step short. projectileExtrapolate / projectileRewind counted their 1 ms steps as (int)(t / kMoveStep), and 1.0 / 0.001 in float is 999.99..., so a 1 s extrapolation ran 999 steps plus a straight 1 ms: a step of thrust short (0.016 m/s at 16 m/s2, 0.004 at 4, under the old test's tolerance). Now + 1e-3 before the cut (and a rest never below 0): the closed form is 64.0000 m/s at 1 s against the server's stepped 63.9997.
  • Protocol 58. Every copy of a rocket's path (prediction, rocket riding, the hook probe) flies with the thrust, so a 57 client would mispredict rockets. No layout changed; the demo converter covers older demos as before.
  • Rocket riding flies its anchor with the same thrust, so the rocket you ride pulls away faster. Bots lead by projectileFlightTime with the new thrust (nothing to change).
  • Test. testRocketThrust: 64 ±0.01 m/s after 1 s, 48 + 16 x 1.001 / 2 m along; the base table's 48 m/s and the lob's 33.6 m/s checked. testRocketRiding hooks your own rocket once it is 2.2 m out (past the 1.2 m release distance + 1 m), which took the real-time race out of which command crosses it.

Weapons: the primary rocket accelerates (owner, 2026-10-30: "primary fire rocket should have an acceleration of 4m/s^2 on top of its current velocity / secondary unchanged")

  • What. kRocketAccel = 4 m/s² (gamedefs.h), along the rocket's velocity, from its 48 m/s launch speed: 52 m/s after 1 s, 50.0 m covered in that second (48 + 2), 68 m/s at the end of its 5 s life. The lob (gravMul kRocketLobGravity, PROJ_LOB) gets none: projectileAccel(type, gravMul) is the weapon set's rocket.accel for the primary rocket and 0 for everything else. A rubber bounce turns the velocity and the thrust follows it (it is along the velocity, not the launch direction).
  • No speed cap. The most it can gain is 4 x 5 s = +20 m/s (68 m/s, still under the crossbow's 76.5), and a rocket that far out has long left any fight, so a cap would only be another number to keep in step on every copy of the path.
  • One path everywhere. A step is now projectileStepVel (the drop, then the thrust along what that leaves) in Server::stepProjectile, pmove's rocket-riding anchor and the rockets its probe flies on, and Map::flyProjectile's slow-mo steps. projectileExtrapolate / projectileRewind take a ProjFlight (drop + thrust) instead of a gravity: a thrust with no drop is a constant acceleration vector (it stays along the velocity), so the closed form is still one pass and exact (the old gravity sum with a vector), and the rewind is still its inverse. Only a weapon set that gives the primary rocket a drop as well bends the thrust; those few are stepped (the same steps, forwards and back) instead of summed. Every caller (the client's drawing and snapshot extrapolation, your rockets' predicted copies, the hook probe, the server's rewind for catching someone else's rocket) passes projectileFlight(type, gravMul).
  • Wire: protocol 55. Riding a rocket, pmove flies the anchor with the rocket on both sides, so it needs the thrust: MoveState hookAccel (and HookProbe / RocketCopy / HookCatch accel), set by the server every tick like hookGrav, and written after it in the own movement state (kSnapRocketThrustSince; the read clamps it to the field's 0-100 range). An older client would mispredict every rocket anyway. Demos: the converter reads a 54 header without it (no thrust) and writes today's layout, so older demos convert at startup as before.
  • Bots. They lead a rocket by projectileFlightTime (solving d = v t + a t² / 2) instead of distance / speed: at 40 m that's 0.81 s, not 0.83 s, about 20 cm less lead on a target running 8 m/s. The maptool's weapon range uses the same time for its aimed shots.
  • Weapon sets and stats. New field rocket.accel (m/s², 0-100, default 4; 0 turns it off). The weapon stats' travel times to 10 / 20 / 40 m count it (0.207 / 0.410 / 0.806 s, were 0.208 / 0.417 / 0.833), and the maptool's stats row notes it.
  • Test. testRocketThrust (weapons group): a server-stepped primary rocket is at 52 ±0.01 m/s after 1 s, still on its line, within 2 mm of projectileExtrapolate and 50.002 m along; projectileRewind brings it back (1 s and 0.2345 s); the lob keeps its horizontal speed and drops 13.5 m/s²; the stepped drop-and-thrust path matches its steps and rewinds. The snapshot round trip carries hookAccel, and a protocol 54 header reads as no thrust.

Modes: neutral payload's clock scales with the players (owner, 2026-10-30: "npayload round timer should be 2 minutes per total players")

  • What. New mode param roundTimePerPlayer (modeparams.h, s, 0-3600, default 0 = off; common.qlogic declares it and its roundLength slot uses it). Above 0 the round clock is it times the players on teams A and B when the round goes live (at least 1), and it beats roundTime. Neutral payload sets 120 (npayload.qlogic too): 1v1 4 min, 2v2 8, 4v4 16 (it was a flat 5 min). roundTime 300 stays as the number a custom mode falls back to with the per-player clock set to 0.
  • Why at the round start. The clock is set once, when the freeze ends (roundLength is read there only), so someone joining mid-round doesn't move it; the next round counts them.
  • Test. The neutral payload test (bots group): a 1v1 goes live with a 240 s clock.

Tests: testBotFalls plays on a lava moat again (engine work list, 2026-10-30)

  • What was wrong. testBotFalls (group botfalls) said it played experts on "Dojo (a platform in a lava moat)". That was the old Dojo; the rebuilt Dojo (a hall, tatami, garden: maps branch) has no lava, so the test would have stopped testing edges at all once it lands.
  • Now. The test writes its own map, zz_lavamoat (lavaMoatMap(), the old Dojo's layout: a 24 m platform, a 4.5 m lava moat round it with its bed 1.4 m down, a ring path outside at the platform's height, walls), into the user maps folder for the run, checks it is what it says (lava under three points of the moat, none on the platform or the ring, 1v1 offered), plays 90 s of expert 1v1 on it, and removes it. The name is fixed (the bots' seed comes from it), and zz_ keeps it out of the every-map checks. Foundry (tag, lava trenches) is unchanged.
  • Result. Lava moat: 0 falls, 0 self-kills, 6 deaths in 90 s (the old Dojo: 0 falls, 3 deaths); the limit stays at 2 falls and 1 self-kill.

Settings: the page strip, and pages that ran into their footer (owner, 2026-10-30)

  • The page strip. Owner: "in the settings pages at the bottom you should be able to see all the page numbers, and on the end they should have left and right arrows, which you can also go to by pressing left and right arrow". Every tab with MORE pages (PLAYER 2, GAMEPLAY 5, CONTROLS 3, VIDEO + AUDIO 9, HUD 2; CROSSHAIR has one page and no strip) draws < 1 2 ... N > in the footer's middle (Game::drawSettingsPages): the 376 px between BACK and the page's own right-hand button (DEVICES, RESET ALL KEYS, RESET CROSSHAIR...), cells up to 40 px wide, 4 px apart (nine pages: 30.5 px cells), so the strip takes no row from the content. The page open is a filled tab; a number goes straight to its page (Game::settingsPage, which also closes a picker and a key capture, and puts CONTROLS back on its first key page), the arrows step one. Edges: clamp, not wrap (the arrow at an end is greyed out and does nothing): a wrap from page 9 to page 1 reads as a jump, and every page is one click away on the strip anyway. Not shown on FONT, DEVICES, the controls import list, the HUD editors or while SEARCH's results replace the page.
  • Arrow keys. Left / Right (no Ctrl / Alt / Super) step the page in Game::onKey, before the game's binds see the key, only when Game::settingsPageKeys() says nothing else has the keyboard: not while a text field has focus (SEARCH, a name, a slider's or colour picker's typed value: Ui::typing), a key bind is being captured (so an arrow can still be bound), a slider is held, a colour picker or dropdown is open, chat or the console is open, or on the pages without the strip. No other settings widget takes keys (the < > option rows are clicked, they never hold focus), so the arrows had no job there before.
  • Replaced buttons. The single MORE > / MORE n > button and the MORE pages' BACK-to-the-tab are gone (the strip does both). BACK is now the same on every page of a tab: it leaves settings. Esc keeps its old step (a MORE page goes to its tab's first page, then out). DEVICES >, RESET and the other right-hand buttons stay where they were.
  • The bug: content drawn over the footer. On Windows, VIDEO + AUDIO's left column has two rows Linux / macOS don't (GPU MAX CLOCKS, PRIORITIZE GAME, each with a two-line hint: 126 px; 166 px with VSYNC: LATENT SYNC's TEAR LINE row), which pushed FLAT MAP down until MAP COLOR (y 618-650) sat on BACK and RESET MAP COLOR on MORE (footer at y 640). Its right column ended at y 638 with EXCLUSIVE AUDIO, 2 px above the footer. GAMEPLAY's left column ran its last hint (USED WHEN SMOOTHING IS CUSTOM) to y 642 on every platform. CROSSHAIR's COPY / PASTE CODE answer (shown 3 s after a click) was a hint under that row, down to y 640 (found by the new UI test check): it's now beside the PER WEAPON header.
  • The fix: rows moved, nothing shrunk. FLAT MAP (FULLBRIGHT, MAP COLOR, RESET MAP COLOR) to VIDEO + AUDIO page 2 under INTERFACE (that column ended at y 440, now 572; 602 with the integrated-GPU hint). EXCLUSIVE AUDIO (Windows) to page 6 under OUTPUT, after AUDIO LATENCY (its column: 568 to about 624 on Windows). SMOOTHING (INTERP) and SMOOTHING DELAY from GAMEPLAY's NETWORK to page 7's NETWORK, after PACKET LOSS PROTECTION, with the other network settings (that column: 458 to 572; GAMEPLAY's left column now ends at about 494, the page's lowest ink at 546). The settings search index follows by itself (tools/settings_index.py reads the pages at build time).
  • Audit. Every page at 1280x720, 1920x1080 and 1024x768 (Linux, measured): the closest is GAMEPLAY page 4 at 10 px (at 720p) above the footer, then PLAYER 12, CROSSHAIR 16. The Windows-only rows were added by hand: VIDEO + AUDIO page 1 left 526 (566 with latent sync), right 582; page 6 about 16 px clear; page 9's NVIDIA hint +14 px (ends about 400); page 7's two platform hints cancel out. The layout scales with the window height (text a little smaller at 4:3), so no resolution is tighter than 720p.
  • UI test. The strip's PREVIOUS PAGE, PAGE 1 ... PAGE N and NEXT PAGE are each nav to their page (the open page and an end arrow inert), and the arrow keys are key actions doing the same, so every number, arrow and key is clicked / pressed on every page of every tab, in the menus and in game. New layout check: a settings page's content (every 2D quad drawn between the tab row and the footer, labels and hints too, not only widgets: Renderer::uiInkBottom, a pass over that frame's UI vertices, only while the test probes) must end at least 4 px (at 720p) above its footer row, at all three resolutions; the existing widget-overlap check only saw widgets, so a hint over BACK or a row on a platform's extra rows went unnoticed.

Settings: the SEARCH tab (owner, 2026-10-30)

  • What q asked. "search should be its own tab, where it shows every setting in the game in a big scrollable list 3 wide, and whatever you type in the search bar should limit what is seen; you should be able to search the name of the setting, or what it does, so like typing in screen should show the devices, fullscreen setting, ...". It replaces 1.19.4's first search (a box over the tab row's right end that listed up to 14 labels as buttons jumping to their page).
  • One source of truth: the pages themselves. Game::drawSearchTab calls every page's own draw function (PLAYER 1-2, GAMEPLAY 1-5, CONTROLS 1-3 and its two key pages, VIDEO + AUDIO 1-9 and DEVICES, CROSSHAIR, HUD 1-2; 25 in all) with a UiRelocator set on the Ui. Every widget call (button, toggle, cycle, slider, colour picker, dropdown, text field) asks it first: it either skips the widget (returns before any text is fitted or drawn, reports nothing, takes no input) or gives it a cell, where the widget is drawn and works with the very code and config variable it has on its page (right-click to default, typing a slider's value, colour pickers, key rebinding). A setting added to a page later is in the search with nothing else to do. Considered and not done: a registry of every setting's widget as data (a rewrite of ~600 immediate-mode rows), or listing labels that jump to their page (what the old search did; q asked for working rows).
  • Keeping a page's other drawing out. While relocating, Ui drops a page's labels and hints and answers no hover outside the placed widget (so e.g. PLAYER's model preview drag can't react), the renderer's 2D alpha is set to 0 and Renderer::rect / text / roundRect / ... now return at once when it is 0 (no quads; also a small saving for HUD elements at opacity 0), drawModelPreview doesn't run, and a page's state (tab, page, CONTROLS' key page, DEVICES) is put back after each. Left out of the list: navigation (nav expectations: FONT >, DEVICES >, EDIT ON SCREEN, the CONTROLS key-page tabs: they lead to pages that are listed themselves) and things that aren't widgets. A row that opens something of its own page (CONTROLS' IMPORT, when the clipboard holds no controls, opens its file list) takes you to that page with it open.
  • Matching. Every word typed (split on spaces, upper-cased once per change of the query) must occur in the widget's label, its id (a key slot's "CHAT KEY 1", a monitor's "MONITOR: ..."), its value names (a cycle's / dropdown's), its help or its keywords. Help is the hint drawn under the row, read at build time by tools/settings_index.py (each hint attached to the row just before it; kSettingsIndex gains a help field); keywords are a small table there (KEYWORDS, by label or by an id's first part) for words neither has: "SCREEN" on DISPLAY, WINDOW SIZE, FULLSCREEN SIZE, RENDER SCALE, SHARPEN, MONITOR, REFRESH RATE, PRESENT, BRIGHTNESS, FOV MODE; "RESOLUTION", "MIC", "FIELD OF VIEW", "LAG", ... The result per widget is cached in a hash map for the query (cleared when it changes), so a frame does one lookup per widget.
  • The list. 3 columns of 235 px (at 720p), 9 rows on screen (a 14 px tag over each 32 px widget, 54 px apart; the last row ends at y 602, clear of the footer), only the rows on screen drawn. The tag names the page ("VIDEO 3", "CONTROLS: MOVE KEYS") and, for a widget whose label doesn't say what it is, its id ("CONTROLS: JUMP KEY 1", "PLAYER: NAME"). The count of matches is shown by the field. Wheel: 2 rows a notch; scrollbar: drag the thumb, or click the track for a page.
  • Keys. Ctrl+F (Cmd+F) anywhere in settings, or "/" when nothing is being typed (its character isn't typed into the field), opens SEARCH with the field focused; not while a key bind is being captured, chat / the console is open, or in the HUD's full-screen editors. Esc with text in the field clears it (an open colour picker closes first); Esc again leaves settings. SEARCH has no page strip, so Left / Right never turn pages there.
  • Cost. CPU time of the settings panel's page per frame, best 60-frame averages on this (shared, 4-core, llvmpipe) machine: SEARCH 0.3-0.5 ms (unfiltered and "screen"), VIDEO + AUDIO page 2 alone 0.2-0.3 ms, other pages 0.04 ms. Most of SEARCH's first measurement (1.6 ms of it) was VIDEO + AUDIO page 2 and DEVICES calling glfwGetVideoMode (an XRandR round trip) every frame for WINDOW SIZE / REFRESH RATE: Game::desktopMode now keeps a windowed answer for a second (cleared when a monitor is plugged in or out), which speeds those two pages up too. Between full passes a page whose rows are all above or below the screen isn't run (its count from the last full pass stands in); every page runs when the query or scroll changes, on a click, drag or typing, with a picker open, and every 16th frame. Only the settings screen pays any of it, never a match.
  • UI test. New screen settings/search (ingame/pause/settings/search is skipped by host-ffa: the same widgets are tested on their pages in game). The field is local() and is typed "FOV"; the scrollbar is a handle (state("searchscroll")); CTRL+F and / are key actions to it from every other settings page, ESC (CLEAR SEARCH) one while text is typed. A widget in the list is reported as <PAGE> > <ID> (two pages both have an IMPORT button, which the test mixed up) and as optional (the scrollbar's drag shows rows the test then never stops on; each is tested on its own page). Clipped labels aren't layout issues in the list (a third of a page wide; whole on their page). After the menus scenario, searchCheck types queries and fails the run (as SETUP) unless "screen" finds FULLSCREEN SIZE, DISPLAY, RENDER SCALE and WINDOW SIZE, "fov" and "FOV" show the FOV slider on screen, "fov zoom" finds SNIPER ZOOM FOV and "volume announcer" ANNOUNCER VOLUME. config: last_tab accepts 6 (SEARCH).

Bots: the tick and round spawns don't depend on slot order (map worker, 2026-10-30: "the team listed second scores about 3% more, on any map": Mine koth, 48 seeds)

  • What depended on order. (1) In Server::simulate() each bot thought and then ran its command in slot order, so bot 5 decided on a world bots 0-4 had already moved, shot in and killed in this tick (positions, lastFire, deaths), and the per-tick route budget (kPathsPerTick 2) went to the low slots first. A team added first takes the low slots. (2) startRound spawned players in slot order and spawnPlayer scores spawns by distance from and sight of alive enemies: the second team dodged the first team's fresh spawns, and last round's survivors still counted as alive where they stood.
  • Now. (1) Every bot thinks first, all on the tick-start world, then everyone's commands run, starting from a different connected player each tick (tick % players), so no slot is always first. One extra pass over at most 32 slots and a 32-entry command array: no measurable cost. Humans' commands run in the same rotated order. (2) startRound marks everyone dead before the spawns and, in team modes, spawnPlayer ignores enemies spawned in the same batch, so each team's spawn choice is the same whichever went first. Respawns during play are unchanged.
  • Numbers. The 3% didn't reproduce as a significant effect. Share of the team in the high slots: quark_maptool bots Summit koth 180 s, 200 runs: score 0.503 +- 0.010, kills 0.502 +- 0.003; Bastion vs 120 s, 400 runs: score 0.492 +- 0.012; Mine koth 300 s, the 48 seeds both ways: score 0.506 +- 0.020 (pairwise +0.011 +- 0.044). So 3% on 48 seeds is within noise. In-process, Bastion vs 4v4 60 s, 600 seeds each way (1200 runs): before, kills 0.4925 +- 0.0050 (1.5 sigma), score 0.490 +- 0.010; with the tick fix alone 0.4920 +- 0.0051; with both fixes kills 0.5053 +- 0.0050, score 0.506 +- 0.010. Both are within noise; the fixes take away the mechanisms, so nothing depends on order by construction.
  • Test. quark_tests tickorder: a hook hashes every player's position, velocity, health and alive at each bot think and checks every think in a tick sees the same world, no bot thinks twice in a tick and all 8 bots are first at some point; then Bastion vs 4v4, 40 seeds each way, the team-A score share with A listed first vs second within 4 standard errors. quark_tests orderbias [seeds] [map] [mode] [seconds] [perTeam] [firstSeed] measures it at scale (QUARK_LEGACY_ORDER=1 for the old tick order).
  • Seeded tests. The rotated order changes every seeded bot match. testBots's FFA-vs-experts run moved from seed 0x5EED0001 (the standing player now goes 40 s unhit, as 2 of 20 seeds did before too) to 0x5EED0003; over 20 seeds the old and new orders give the same spread (the player's deaths 34 vs 32 in total, kills 3 to 9, grapples 4 to 16).

Bomb: spawns by role, not by team (q, 2026-10-30: the recommended option, confirmed)

  • Why. modeSpawnType started team A on the map's a spawns and team B on b in every round. q's timing rule for bomb maps (the attackers reach a site in about 5 s, the defenders 1 s later) is built for the side that attacks, and team A attacks the first half (bomb.qlogic: attackers = half > 0 and roundNo > half ? B : A). So after halftime team B attacked from the defence's end and team A defended from the attack's: one team had the short way in both halves. On War (115 m vs 144 m) team A won 7 of 8 matches.
  • Now. In bomb, the attackers start on a and the defenders on b (or the map's bomb-a / bomb-b set, format 11), whichever team that is: team == attackers ? a : b. So a means "the attack's spawns" on a bomb map, as the maps were built (team A attacks first). roundSetup sets the attackers before the round's spawns, so every round, halftime included, spawns on the new sides; respawns in the warmup use the first half's attackers (team A). Custom modes based on bomb get the same through mapSpawn. Other modes are unchanged.
  • Checks. The walk check already asks both sides' spawns to reach every site, and the sight check pairs a with b, so nothing changes there: quark_maptool check passes all 15 checks on Depot, Datacenter and War (maps-1196). Quarry isn't in any worktree here.
  • Test. testBombSpawnRoles (group rules spawns): modeSpawnType for each team in each half on Depot and on a map with a bomb-a / bomb-b set, vs still by team; then a real Depot match with halftime after round 1: in round 1's freeze team A stands on an a spawn and team B on a b one, in round 2's team B on a and team A on b.

Glass: sloped panes are their shape, not their box (a map review, 2026-10-30: Museum's glass dome on maps-1196, four 40 x 6 m slabs floating over the roof)

  • What was a box. Only the drawing and a few point tests. The game drew every pane with translucentBox(brush.box) (plain and breakable glass, and the editor's baked view), and the see-through looks and breakable blocks with boxFaces (the box's 6 faces), so a pane on a wedge, corner, tetra or cut drew as its bounding box: a dome face's box is mostly air. Map::glassAt (which pane a shot or bolt is in), blockAt and crumbleAt tested the box grown by 3 cm: where sloped panes' boxes overlap (a dome's four faces all do), a shot on one face could break another. Shards started anywhere in the pane's box. Collision (sweepAxis with brushObstacle), every ray (rayBrush: shots, sight, the grapple) and the dash-through test already used the real shape.
  • Now. Renderer::translucentBrush draws a slope or cut as its faces (slopeTriangles, map.h: a fan over slopePolygons), a box as before; boxFaces gives see-through looks and breakable blocks on a slope their faces too, with the outline distances Renderer::slope uses. brushNear(b, p, eps) (map.h: the box and each sloped plane, grown by eps) replaces the box tests in glassAt, blockAt and crumbleAt. glassShards takes the pane's brush: on a slope a shard's start is drawn again (up to 12 times) until it is in the shape; a box draws exactly the same numbers as before, and the server and the clients (GlassFx.brush, new) pass the same brush, so they still make the same shards. A shattered sloped pane's glitter starts at its pieces' starts when it would be off the shape. No cost for box panes; a slope's triangles are made per frame like the box's quads (no shipped map has a sloped pane yet).
  • Test. quark_tests slopedglass: a breakable wedge, a second one rising the other way in the same box, and plain glass on a cut box of the same slope. Both draw 8 triangles, 157.7 m2, every corner on the shape (the box: 208 m2); the cut is air over its low end and solid under it; a shot down at x 7 hits it at 0.50 m (the box's top: 4); a player box drops onto the slope; glassAt picks the wedge whose shape holds the point (the box test picked the first); a shatter's 600 shards are all in the wedge (290 off it with only the box), and a box pane's shards are unchanged; breaking one wedge leaves the other standing. Checked by eye on Museum (maps-1196): the dome draws as four sloped glass faces with the rafters under them.

Race leaderboard: the race total (owner, 2026-10-31: "race leaderboard time should be combination of all times on all maps; i will complete the maps and give a default time that if you havent played the map that is your "time" on it")

  • Map format 13: racedefault <s>. A race or surf map's default time (1-3600 s; Map::raceDefault, 0 = none), read, written back by Map::serialize (so the editor keeps it) and carried by quark_maptool's JSON as racedefault (so Quarkology's editor round-trips it). Converter step 12 -> 13 changes nothing but the version line (a format 12 map has no default); the shipped hand-made maps (Cobblestone, Surfy) get just that line, as the converter writes it, so they still hash as official. testMapConverter converts a version 12 race map and checks the line's parse, write-back and bounds.
  • Ranked race maps are data, not a name. rankedRaceMaps() (ranked.h) = the official maps built for race or surf (for race, for surf; owner, 2026-10-31: "the total race time also counts the 2 surf maps"), sorted by name, read once from the embedded maps (only maps with such a for line are parsed; a time trial server reads them at start, not on its first tick). Nothing lists map names by hand: a retired map (a legacy map, no longer official, as Viaduct becomes) drops off by itself. Lodestone also offers race (its modes line) but is built for points: not on it. The time trial (Server::timeTrialCounts, the client's split comparison, MY RACE STATS) uses the same list instead of "viaduct".
  • Surf time trials. Surf already shared race's checkpoints, splits, practice and the server's /racerun post (isRaceMode); what was missing was a way to start one and to count it. PLAY TIME TRIAL on a surf map now hosts it in surf (knife only, no dash or grapple), and a time trial counts only in the map's own mode (RaceMapInfo::mode): a surf course run in race mode with dash and grapple would post times surf can't match.
  • Defaults. A map's racedefault line, or kRaceFallbackDefaultS = 600 s until q gives it one (Solar System and Surfy have none yet; Surfy's file is q's and isn't edited). Placeholders on the new race maps (tools/maps/<name>.py, about 3x a good run, until q sets them): Particle Accelerator 225 s (a good run is about 75 s), Jungle Ruins 180 s (about 60 s), Rooftop Run 180 s (about 60 s).
  • Split floors on Surfy are weak: it has one checkpoint (the finish) about 5 m from the start as the crow flies, so its floor is 109 ms and only the Worker's 5 s minimum run guards it. More checkpoints on Surfy would fix that (q's map).
  • Worker: GET /racetotal[?n=25] answers "total_ms maps_run name" per line, fastest first, for every racer with a run on at least one ranked race map. The total is the sum of the defaults plus each racer's (best - default) over the maps they ran, so one grouped query over race_times (joined to a VALUES table of the maps and defaults) answers it; ties go to whoever set their latest time first. A run counts as run even if slower than the default (q's rule: "if you haven't played the map that is your time on it"). Rows on a map that is no longer ranked (Viaduct's) stay in race_times and count nowhere. No D1 schema change, so no migration. /raceboard?map=M still answers each map's board, and /crowns lists each ranked race map's top 10 as before.
  • The Worker's map table RACE_MAPS (name -> defaultMs and split floors; replaces RACE_MAPS + RACE_FLOORS) is generated: build/quark_tests racefloors prints it, and quark_tests fails if worker.js's copy differs from the maps. Chosen over the game server sending the list: a server can't change what counts, and the Worker answers the board with no server involved.
  • Game. LEADERBOARD's fourth column is RACE TOTAL (top 10, then YOU: your total and maps run, your time on each map, grey at its default, and "UNPLAYED MAPS COUNT AT THEIR DEFAULT TIME"); a TRIAL MAP cycle picks the map PLAY TIME TRIAL starts (--timetrial --map NAME too). MY RACE STATS lists the ranked race maps first and says what the map adds to the total (your best time trial, or its default). Your own total is worked out from your local runs (race_times.log), the board's from the Worker.
  • Tests. testRaceFloors checks every ranked race map (every official race / surf map is on it, floors, its default or the fallback, above the fastest plausible run, raceTotalMs) and the Worker table; testTimeTrial also checks a surf map's trial counts in surf and not in race; testTimeTrial runs on the first ranked race map; tools/master/test.mjs runs the race tests on a map from the table and adds the total (defaults for unrun maps, a second map lowering a total, n, a retired map's rows ignored).

Maps: the old pool retired to maps/legacy/ (q, 2026-10-30: "every current map goes except q's two hand-made ones, surfy and cobblestone"; 2026-10-31: a "Legacy maps" setting, default off, instead of deleting them)

  • Build. tools/make_maps.py keeps the 20 retired modules (clockwork, depot, summit, canal, harbor, bastion, foundry, temple, colosseum, furnace, gantry, anvil, viaduct, hamlet, downtown, orbital, lodestone, midtown, hightown, cascade) in a new LEGACY list and writes them to maps/legacy/<name>.qmap (--list-legacy; CMake makes them outputs of the map step). They are not passed to tools/embed_maps.py, so they're not official: not in kOfficialMapData, never ranked, no shipped light bake (a first load bakes, Midtown ~5 s). Packages already copy all of maps/, so maps/legacy/ ships on every platform with no packaging change. make_maps.py also deletes a generated copy of a legacy name left at the top of maps/ by an older build (it would list as an unofficial map). .gitignore ignores maps/legacy/.
  • Listing and loading. setLegacyMapsListed() (map.h): off, listMaps() lists the shipped and your maps folders as before; on, it also lists the shipped maps/legacy/ and your <maps>/legacy/ (names deduplicated, so a current map of the same name wins). mapFilePath() looks in the legacy folders last whatever the setting says, so --map temple, an old server config, a vote from an old client, a demo or the shipped Hamlet benchmark demo keep working. isLegacyMap() tells the picker: the title gets " (legacy)". The client sets it from legacy_maps (GAMEPLAY > MAPS > LEGACY MAPS, default off for everyone, upgrades too) at start and on toggle (and rescans); the hosted server's map list (Server::start -> listMaps()) and the map vote follow it. The dedicated server takes --legacy-maps.
  • Your maps folder (moveOldOfficialMaps, once per version). Older versions and launchers copied official maps into userMapsDir(), where a copy shadows the shipped map. At the first start of a version (the game client, skipped in automated runs like the map converter; and the dedicated server), each top-level .qmap whose first line is # generated by tools/make_maps.py and has no handmade line moves to <maps>/legacy/ (a taken name becomes <name>_2, ...; an identical copy is just removed), with its <name>.mapedit.json; its light caches named after it (<maps>/<name>.qlm, cache/<name>.qlm) are deleted (they rebuild). Hand-made, editor-saved and custom-named maps, maps/downloads/ and every subfolder are never touched. Retired generated maps left at the top of the install's maps folder (an unzip over an old install; the launcher's swapInstall already drops packaged files the new version lacks) are removed, or moved into maps/legacy/ if it lacks them; a read-only install is left alone. A stamp <user>/legacy_maps.stamp holds the version, so later starts don't scan (no startup cost). What moved is logged (maps: temple.qmap: moved to legacy/temple.qmap). resetOfficialMaps also removes the retired names from the install's top level.
  • Defaults off the old maps. Host map (host_map) and the dedicated server's default: bastion -> sewer (built for free for all, the default mode). Menu background: clockwork -> castle ("BACKGROUND: CASTLE"). The UI test hosts qk1 (was temple), edits a copy of sewer (harbor) and plays hide on museum (hamlet); tools/test_editor.sh edits qk1; tools/server/install.sh's example is --map greenhouse. INFO > MAPS lists the new pool. A ranked server configured for an old ranked map (temple, furnace, gantry, anvil: legacy now, never ranked) logs it and plays its ladder's designed-for map instead of refusing to start, so the live servers survive the update without a config change.
  • Tests. testLegacyMaps (group resetmaps): listMaps with the setting off and on (shipped and user legacy folders, a current name winning), loading by name either way, every legacy name unofficial; the move on a made-up user folder (a generated copy with sidecars moves, a name clash gets _2, the hand-made, custom, downloads and subfolder maps stay, the install's retired copy goes) and the stamp (the same version doesn't scan again, the next one does). testResetMaps runs on qk1.
  • Docs. MAP_STRUCTURES.md: the retired maps' sections are dropped (they're in git history), a table of the new pool and a legacy-maps note added. RELEASE_CHECKLIST.md: the mode rows name the new maps, plus rows for the legacy toggle / folder move and the ranked defaults.
  • Mode coverage (q's target: 2 built for each mode, 5 playable). No map is built for duel, tourney or one-way payload (duel plays 14 maps, tourney 11 1v1 maps as q allows, payload only Railyard); bomb is playable on 4 maps, npayload 4, race 3, points 2.

Ranked: qk1-8 on every ladder, each ladder's designed-for map, fairness instead of symmetry (q, 2026-10-31: "qk1-8 can be played in instagib, vanilla, and lgvamp. lg vamp is designed for dojo, 1v1 vanilla is designed for greenhouse, instagib is designed for spire"; 2026-10-30: "Maps never have to be symmetrical")

  • Ladders. qk1-qk8: ranked vanilla vampire instagib (lgvamp = the vampire ladder, laser vampire). Greenhouse gets ranked vanilla, Spire ranked instagib; Dojo keeps ranked vampire. Their for lines already fit (Greenhouse and Dojo 1v1, Spire ffa: ranked instagib is a free for all).
  • Designed-for map. rankedDefaultMap(ladder) (gamedefs.h): greenhouse / dojo / spire. Going ranked (Server::setRanked) and the client's ranked pickers (bestRankedMap) take it first when it's ranked for that ladder; else, as before, the map ranked only for that ladder, else the first that takes it. Before, the default was whichever map was ranked for that ladder alone (temple, furnace, gantry); with qk1-8 on all three that rule would have picked qk1 for vanilla and instagib.
  • Fairness, not symmetry. The ranked check required every solid, spawn, ladder and light to have a point-mirrored twin and the sun overhead, which Greenhouse and Spire aren't. It now checks fairness: for a ladder played 1v1, as many A as B spawns, and on the walk grid (Dijkstra, horizontal length plus climb at run speed) the two sides' mean run to the nearest enemy spawn, and to the middle of the map, within 1 s of each other. A hard failure for generated maps (human-made: reported). Instagib's free for all draws everyone from one pool, so it needs no side check; spawn sight and spacing are testSpawnSanity's, for every map. The test's "3 maps ranked for one ladder alone" rule becomes "each ladder's designed-for map is ranked for it" (and 2+ maps per ladder). prefabs.top_up_spawns keeps spawns in mirrored pairs only on symmetric ranked maps (a ranked map with the sun overhead: the qk maps, Dojo), so Greenhouse's and Spire's pools top up as before.

Hide and seek: a 5 s head start (q, 2026-10-31: "reduce hider time to 5 seconds")

  • kHideTime 30 -> 5 s (gamedefs.h): the freeze while the seeker faces the wall. It feeds the native default (ModeParams::hideTime), the mode logic's hideTime param default (modelogic.cpp, now kHideTime instead of a literal 30) and tools/modes/hide.qlogic (param hideTime f32 = 5). A custom mode that sets hideTime keeps its own value.
  • No protocol bump. The client never derives the head start: the freeze is a server phase, and its end reaches clients as the phase timer in the mode state, the same as before; the "hide! (N s)" line is server text. An older client on a 1.19.6 server shows a 5 s freeze correctly, and the other way round a 30 s one.

Race and surf: no time limit (q, 2026-10-31: "in race and surf modes there should be no time limit lol why is there one")

  • What changed: race and surf rounds had a 300 s round clock (sized for two loops of Viaduct), and a round with no finisher ended with "nobody finished". The mode default roundTime for race and surf is now 0, so there's no clock and a round runs until someone finishes (first to finish wins it, as before). A host or mod that sets a clock still gets one (tickRace only ends a round on time when phaseEnd_ > 0).
  • Split times were worked out from the clock (liveLength_ - (phaseEnd_ - now)). They now count from the round going live (roundStart_), which gives the same number when there is a clock.
  • The HUD's run time (to the hundredth) was 300 - timeLeft, which only worked while the clock existed. It's now the client's own stopwatch from the round going live, shown in every race and surf round, not only the time trial. The splits and the board still use the server's times.
  • The built-in mode scripts (tools/modes/race.qlogic, surf.qlogic) declare roundTime 0 too, and their tick ends a round on time only when phaseEnd > 0 (with no clock, now >= phaseEnd would have ended every round the moment it went live).
  • No protocol change.

Rockets: splash did no damage on servers (q, 2026-10-30: "explosions aren't registering, they do no damage on my Oracle box server"; direct hits worked, splash did nothing, no hit markers)

  • Cause. Since 1.19.3 the shooter's game reports its own rocket hits (Game::reportImpact -> reportBlast, hit commands) and Server::explode leaves every victim but the shooter to that report. The game reported the blast at the exact point its rocket's path met the floor or wall (q.pos + d / len * hit for the predicted copy, pr.pos + d / len * hit for the server's copy drawn ahead). reportBlast's cover test then cast its rays from that point, on or (by rounding) a hair inside the surface, and Map::raycast hits a box at t = 0 from a point on its face, so the centre ray and both F16 rays were "blocked" and every splash victim was skipped: the report went out with no victims. The server's own blast never had this: stepProjectile sets it off 5 cm short of the surface. A direct hit's point is on the player's box, not the map, so it still counted.
  • Not ping. Reproduced on a local dedicated server (1v1, 250 Hz, a UDP delay proxy at 20, 80 and ~220 ms round trip, with jitter and loss) with two real games under Xvfb, the shooter aiming at the floor 1.2 m beside a standing player: 1.19.3 counted 4 of 20 blasts the server would have hit (the 4 came from the "vanished projectile" path, which reports from a point in the air), the fix 14 of 14. Any reported blast on a surface was affected, at any ping.
  • Fix. blastPoint (pmove.h, kBlastBackoff 0.05 m) is where a projectile goes off: 5 cm back along its flight from where it met the surface or player. stepProjectile uses it (unchanged: it was max(0, best - 0.05)), and reportImpact backs every reported rocket blast off the same way. The cover test is one shared function, blastReaches (pmove.h), used by both Server::explode and Game::reportBlast, so the two can't drift apart again. Where the explosion is drawn (localBoom) is unchanged. No wire change.
  • Test. testClientHits (group materials): a floor blast 0.8 m from a player reaches them from blastPoint, not from the surface point (exactly on it or 1e-5 inside), and a player under the floor stays behind cover.

Votes: no legacy maps unless the server lists them (owner, 2026-10-31: "you cant vote the old maps on the new testing server update")

  • A legacy map loads by name (an old config or demo still works), so callvote map summit koth used to pass. A map vote for a legacy map is now refused ("<map> is a legacy map (this server doesn't list the old maps)") unless the server runs with --legacy-maps. testMapVote checks the refusal and votes Glacier Summit instead, and its every-map loop still votes every listed map in every mode it supports.
  • A dedicated server set up for a legacy map (an old --map, unranked, legacy maps off) now plays the listed map built for its mode instead (else the first that takes the mode), so nobody lands on a map they couldn't vote back. A ranked one already switched to its ladder's map. (It's in quark_server's start, not Server::start: there it also swapped the maps of the in-process test servers and broke ~370 weapon/rules checks.)

Servers: a new install plays unranked laser vampire on Dojo (owner, 2026-10-31: "default server position should be dojo ranked lg vamp for future updates", then "actually unranked")

  • tools/server/install.sh with no server options on a first install now runs --mode 1v1 --variant vampire --map dojo --public (it was --public: FFA on the default map). Re-running it still keeps an installed server's options.

Solar System: q's own stage designs (owner, 2026-10-31: "so i can design the maps, you just build them")

  • q laid out each of the 15 stages (Sun to Pluto) as a list of pieces in the Solar Stage Builder. Claude drew each one top-down, and q approved it before it was built. tools/maps/solar.py builds exactly those lists, in the approved layout.
  • Surf conventions (q):
    • Every ramp has its own gap before the next, and an air piece is just an extra-big one.
    • Ramps run about 3 s and flights about 2.2 s at the speed reached there.
    • Left/right/top/bottom walls are kill walls standing in the flight, death floor to ceiling, covering 2/3 of the lane so you strafe to the open third.
    • A speed check is a solid full-width wall, its top 1.2 m under an 85%-speed flight. Its height is an estimate for q to tune in the editor.
    • Turnback legs are split by a wall you ride round.
  • Geometry:
    • Gaps are ballistic flights at g=24, placed for a run at 85% of the ride's speed.
    • The next ramp starts as steep as the flight falls there (at most about 50°), so you land along the face.
    • Faces are 56-62°, 20 m wide (10 m for narrow ramps and split lanes).
    • Curves are pieces of at most 3 m and 5°.
    • Walls stand 24 m each side of the line. Lower sections passing under higher ones get a kill-liquid roof.
    • Jupiter's helix has a centre column, so you can't drop through and skip the spiral.
    • Each stage ends on a platform with a framed gate; Pluto's is the finish room.
  • Some flights are very long (Mars 218 m, Asteroids 292 m, Io 252 m), so later ledges sit high (Pluto's at 1309 m) and speeds reach about 110-130 m/s. That's for q to tune.
  • Back in MAPS, and on the ranked race total (RACE_MAPS, racefloors) with racedefault 600 as a placeholder until q sets the time. About 18,300 brushes.

Weapon switch: a held scope doesn't lock fire (owner bug report, 2026-10-31: "when holding scope on rifle and switch to laser you cant shoot until you un hold right click")

  • Cause: switching with fire OR alt-fire held locked fire until both were let go, and a held scope is alt-fire. So rifle (scoped) -> laser locked the laser.
  • Now only the buttons that fire the new weapon lock it: fireButtons(w) = fire, plus alt-fire where it is a shot (knife/syringe throw, shotgun bounce, rocket lob, bubble) or the sticky detonation. It doesn't include the rifle/sniper scope or the laser pull. The same rule applies on the server, in the client's mirror and in weapon prediction.
  • The game also ignores an alt-fire held over a switch until it's let go, so the laser shoots normally rather than as a pull. Toggle-scope already dropped it on a switch.
  • A knife thrown with right click, landing back on the sniper with the button still held: the sniper is scoped and isn't locked (it was locked before). Tests: testSemiAuto (scope held rifle -> laser, then fire: fires) and weaponpredict.

Syringe: half the healing (owner, 2026-10-31: "half syringe healing")

  • Self-use 10 -> 5 HP (the SYRINGE row's damage, which is its heal), still once per 1 s cooldown. The thrown heal on a teammate is 30 -> 15 HP (kSyringeHeal). The throw's cooldown resets are unchanged. A custom mode's syringe damage scale still multiplies both. testSyringe and the throw-reset test have the new numbers; the release checklist says 5 / 15.

Clean surfaces on every look (owner, 2026-10-31: "clean surfaces should apply to the custom surfaces too not just default ones")

  • VIDEO > CLEAN SURFACES only took the panel seams and shade noise off the palette materials (0-11). Now every look from 12 on (ice, sand, wood, brick, the 400+ surface looks, the large patterns as their base, the see-through and feature blocks) draws as its average, flat: its albedo on tops and on sides separately (grass: green tops, earth sides), its see-through alpha and its glow. Liquids keep their motion.
  • How: when it's on, Renderer::buildCleanMeans draws each look the map uses once, with the map's own world program and this frame's palette, as a 4 m square (64 px) on a top and on a side. It averages the pixels into a small RGBA32F texture (texture unit 5, a texel per material, 3 rows). The world shader then reads that in place of running the look. That's cheaper per pixel with it on, and unchanged with it off (one uniform branch). It's redone only when the world program, the palette or high contrast change: a few dozen 64 px draws and one readback while a map loads.

Legacy move: only copies named like a shipped map (owner, 2026-10-31: "solar system 12 doesnt load in game... i can only see up to solar system 9")

  • moveOldOfficialMaps moved every untouched generated map (first line # generated by tools/make_maps.py, no handmade) from your maps folder to maps/legacy/, whatever its name. On q's PC that took solarsystem1-12 (live playtest copies) and solar.qmap, along with older copies of the new pool. It now also needs the file's name to be an official map's, a legacy one's or a retired one's (arena, tug, duel, sprint, crucible): those are the copies that shadow a shipped map, and anything else is yours. testLegacyMaps adds a generated map saved as solarsystem12, which stays.

Map editor: sloped and cut pieces are picked by their solid (owner, 2026-10-31: "in the editor the hitboxes for the blocks are super fuckey, with the custom cut face blocks")

  • edPickAll tested every box and group part against its bounding box. A cut box (or wedge, corner, tetra) was clicked through the empty part of its bounds, so the click took it instead of what was behind, or the wrong one of two neighbours. Non-box shapes now use rayBrush, the same ray-vs-solid test shots and the grapple use (box slabs cut by the sloped planes). Plain boxes keep the bounds test and its inside-the-box rule.
  • (q, 2026-10-31: "smooth faces should turn all faces into single colors") With it on, the palette materials drop their remaining detail too: the metal's brushed streaks, nograpple's stripes and the walls' darkening near the floor. Water, lava and acid become one still colour each. Every face is a single colour.

Step-up on cut boxes' upright sides (owner, 2026-10-31: "sometimes you cant stepup on cut boxes even though the height distance is still below 0.55")

  • slideMove treats any face of a slope or cut box that isn't within 45 degrees of flat as a wall (wall), and the step-up ran only !steep. So a cut box's upright side at an angle in plan (n.y = 0) blocked like a steep ramp, while an axis box of the same height was stepped onto. An upright face (|n.y| < 0.01) is now a riser. The step-up is tried, taken only if it ends higher, and it gives back the speed the face took (unless dashing). Sloped faces steeper than 45 degrees still never lift you. testSlopes: a 0.4 m cut box with a 45-degree upright side ends at 0.40 m; a 0.4 m 63-degree wedge stays at 0.

Flush faces: the thinner brush's face shows (owner, 2026-10-31: "the FACE of the SIDE OF THE STAIRS is not attached to the object itself", "basically every map does it")

  • Where two boxes' faces lie in the same plane, face the same way, overlap and differ in look, the world mesh lifts one a hair off the plane so they don't z-fight. It used to lift the higher material index, whatever the shapes, so a staircase built into a thin rail (Dam's valley stair: steps x -32..-27, rail x -27.1..-27, sharing the plane x = -27) drew its steps' side faces over the rail's outer face: the stair profile showed on the outside of the rail.
  • Now the face whose box reaches less far behind the plane goes on top: a decoration set flush into a wall (thinner than the wall) still shows; a box or staircase overlapping a thin wall (5 m deep behind it vs the rail's 0.1 m) stays behind the wall's face. Equal depth: the higher material, as before. Same look: nothing lifted (no z-fight to hide).
  • The lift is now 4 mm a level (a face over a lifted face goes one level higher) instead of 2 mm times the material index. With 596 looks that index runs into the hundreds, so faces floated far off their boxes: Monorail's deck top (polishedconcrete, look 362, over its flush white edge bands) drew 72 cm above the real deck, hiding the cart line and the bases of things on it, and Dam's stair sides (boardformedconcrete, 397) stood 79 cm out from the stairs (owner, 2026-10-31: "the monorail platform face being lifted off of the actual surface"). Lighting bake unchanged (no rebake).

Pattern fade: every look fades to its average when it's too fine to draw (owner, 2026-10-31: "if you are far away from a patterned surface it should fade, like how lava does, because many many surfaces have the same problem lava does; depends on angle and screen resolution, so should have a setting... where lava changes now is the default for all textures")

  • The world shader measures each pixel's footprint on the face in metres (fwidth of the face-plane position: grows with distance, a grazing angle and a lower resolution or render scale alike). Past PATTERN FADE's start the surface look (every material from 12 on: the 596 looks) blends to its average colour, alpha and glow; at the end it's all average.
  • The averages are CLEAN SURFACES' (each used look rendered once on a 4 m top and side and averaged into uCleanTex), now built whenever pattern fade or clean surfaces is on: no cost per frame.
  • Default = lava's own numbers: lava's veins fade between 0.25 and 0.5 noise cells a pixel at 0.35 x 4 cells a metre, i.e. 0.18 to 0.36 m a pixel, so every look now starts fading at 0.18 m a pixel and is flat at 0.36. FADE DISTANCE (0.25x-4x) scales the start (2x: fades from twice as far), FADE LENGTH (1.25x-8x) is the end as a multiple of the start. The liquids' and noise surfaces' own anti-aliasing (noiseFade) follows the same two numbers. PATTERN FADE off: as before.
  • Speed: a fully faded pixel doesn't run its look at all (one texel fetch), so far floors and walls of expensive looks (cobblestone's Voronoi) cost less: better lows on big maps. Part-faded pixels pay the look plus two fetches.
  • Config keys pattern_fade, fade_dist, fade_len.

Alt-tab: frames stop, the game doesn't (owner, 2026-10-31: "you shouldnt pause the server when hosting if you alt tab it should only pause the frame generation")

  • Settings item #76 (pause offline games when unfocused) was skipped as needing a server-side pause; q's ruling replaces it: nothing pauses, only drawing stops.
  • bg_draw (DRAW IN BACKGROUND, off by default): while the window is unfocused or minimized, Game::frame runs the game (network, prediction, sounds, the match) and returns before the render section, and the main loop skips the latent-sync wait, the swap and the low-latency fences. The loop then runs at BACKGROUND FPS LIMIT (60 when that's "same as FPS limit"), so the network is read and commands sent as before, with no GPU work at all. On: drawn at the background limit, as before.
  • The hosted server was never paused by alt-tab: it runs on its own raised thread, the process priority only drops back to normal (not below), and Windows 11's background timer and power throttling were already opted out of (net.cpp). This removes the last cost of the background game, the frames nobody sees.
  • Automated runs (screenshots, benches, the UI test, backdrop shots) always draw: a headless window is never focused.

1v1: winner stays, spectators rotate in (owner, 2026-11-01: "make 1v1 modes swap in specators cyclicly winner stays, even ranked")

  • Which modes: arena (1v1, ranked's mode) and a duel with team size 1; never vs bots. Server::oneVsOne().
  • The line. ServerPlayer::queueSeq (0 = not in line) from a counter (queueNext_), so join order decides. A newcomer autoTeam seats as a spectator joins it; team a / team b on a full 1v1 joins it (unranked still calls the let-in vote too; ranked, which refused before, and a ranked match in progress now queue you); team spec leaves it (the only way to watch without playing next). Each gets a message saying where they are.
  • The rotation. enterMatchEnd keeps the winning team (matchWinner_); when the match end screen is over, before rulesReset, rotateWinnerStays moves the loser to spectators at the back of the line and seats the first in line on the loser's side. A draw (no winner) or an empty line: the same two play again. The ranked result is posted at match end as before, so each ranked match is rated between the two who played it, and the next match gets its own ticket.
  • Leavers. In warmup, fillOneVsOneSeats gives an empty seat (a player gone for good, after the reconnect grace) to the first in line.
  • No protocol change: the line lives on the server, the messages are ordinary events.
  • Test: quark_tests duel (testWinnerStays): join order, the loser to the back, a draw, cyclic order over three matches, leaving the line, a leaver's seat.

Winner stays mode (owner, 2026-11-01: "make a new winner stays mode, its like 1v1, but winner stays 1 round at a time first to 10 wins is the winner")

  • MODE_WINNER 15, "winner" / WINNER STAYS; protocol 59 (an older client has no mode 15). isOneVsOneMode() = 1v1 or winner stays: everything 1v1 does (one human a side, the drain, the short round end, the rulesets, the HUD clock) winner stays does too.
  • Rounds, not matches, rotate. The 1v1 line (queueSeq, from winner stays for 1v1 matches) turns every round: endRound keeps the round's winning team (roundWinner_), and when the round end screen is over winnerStaysNext puts the loser at the back of the line and seats the first in line on the loser's side (a draw: the same two again), then fills a seat a leaver left.
  • Score = round wins. A round win is +1 to the player's own score (a kill scores nothing here, nativeKill and winner.qlogic's slot kill); scoreA / scoreB always show the two seated players' totals (Server::syncWinnerScores), so the match ends through the ordinary first-to-rounds check at 10 (rounds 10). The scoreboard shows the score (round wins).
  • Mode logic: tools/modes/winner.qlogic, with a new object line and its step winnerStays() (MA_WINNER_STAYS); the native rules match it.
  • Maps: winner stays plays on 1v1's spawn set (spawnSetFor, map.h), so there is no map format change; prefabs.py offers it (and tourney) on every map that offers 1v1. The menus pick a map built for 1v1.
  • Not ranked: the ladders stay 1v1 and FFA.
  • Test: quark_tests duel (testWinnerStaysMode): the loser to the back each round, round wins as the score, the match over at the 10th.

Tourney on any 1v1 map (owner, 2026-11-01: "tourney shouldnt have map overrides, tourney + 1v1 can be played on any 1v1 map")

  • prefabs.py adds tourney to the modes line of every map that offers 1v1 (before, only the qk maps, Greenhouse and Orbit listed it), so the host picker, the vote and the TOURNEY MAPS rotation list every 1v1 map.

Alt-tab sound burst (owner, 2026-11-01: "when you are alt tabbed out of the game and tab back in it plays like 200 sounds at once")

  • Each queued sound carries when it was played (Voice::queued); the mixer drops a sound effect that waited over 0.2 s (an announcer line 1.5 s) instead of starting it late. A device that stalls in the background (exclusive audio gives way to another app) had kept up to 256 sounds pending and started them all together.
  • At most 32 new sound effects start in one mixer period (the newest); the rest of a burst is dropped. A backlog of events handled in one frame stays a few sounds.
  • With the master gain at 0 (MUTE IN BACKGROUND, or volume 0) nothing is queued at all.

Accuracy: each weapon's own, averaged (owner, 2026-11-01: "it should count as percent of shots per weapon averaged not total shots because 30% lg for 1 second means 300 shots landed and 700 shots missed and will lock your acc to lg")

  • ServerPlayer::wpnShots / wpnHits per weapon; ACC (PlayerSnap::acc) is the mean of the fired weapons' hit percentages. Example: laser 30% over 100 shots and rifle 80% over 10: 55% (by total shots it was 34%).
  • The match summary's accuracy is the server's now (it was the client's total over every shot).
  • Reset: every match start (startMatch) and every new player, as before (resetAccuracy). Test: testAccuracyPerWeapon.

Health bars in ranked (owner, 2026-11-01: "because healthbars are enabled they should be allowed in ranked")

  • The ranked check on the health bars over players is gone (game.cpp): ranked draws them like any other match when HEALTH BARS OVER PLAYERS is on (the default). The server already sent visible players' health in ranked; nothing else changes.

Dash charges stay at two (owner, 2026-11-01: "also only 1 dash charge default", then "nevermind change the dash back")

  • Testing build 1.19.3.23 had one charge (protocol 60); kDashCharges is 2 again and the protocol is 61, so a 60 client (which would predict one) can't join a 61 server. The tests are back to the two-charge defaults.

Planting / defusing keeps knockback (owner, 2026-11-01: "when defusing/planting you take no knockback this is wrong")

  • The planter / defuser was MF_FROZEN (the round start's freeze), which zeroes velocity every step. Now bombRooted (protocol.h) drops the command's movement, jump, dash, grapple and fire instead, on the server (runPlayerCmd) and in the client's own prediction (NetClient::tick), so knockback, blasts and grapple pulls move them and friction slows them as usual. bomb.qlogic's slot frozen is the freeze phase only. Pushed out of range, the plant / defuse stops (bombStep's range check, unchanged).
  • Test: quark_tests bombsites (testPlantKnockback).

Votes: one VOTE builder for mode, modifiers and map (owner, 2026-11-01: "just a vote button / then you can toggle on options gamemode modifiers map / under gamemode you pick objective based or player based ... pick gamemode first, then you get to see modifiers, then maps")

  • Pause > VOTE > MAP / MODE / MODIFIERS opens the builder (drawVoteSetup, game.cpp): three toggles (all on by default; the modifiers page starts at the standard rules, so leaving it as it is votes the standard rules), then each part that's on, in the order q asked for. GAMEMODE: OBJECTIVE BASED (bomb, koth, neutral payload, payload, multi point, race, surf) or PLAYER BASED (1v1, vs, ffa, tag, hide and seek, power, winner stays, duel, tourney), then the mode list filtered to that group, then the ruleset for modes that have one. MODIFIERS: steppers for damage, speed, gravity, jump (x0.25 steps), knockback, health (25 steps), and VAMPIRE / REGEN / INSTAGIB / SELF DAMAGE / DASH / GRAPPLE switches, STANDARD resets them. MAP: the live map picker (the maps offered for the chosen mode). Then a summary (MODE, MODIFIERS, MAP; a warning when the map isn't built for the mode) and CALL VOTE. The ranked server's vote page is unchanged (ladders only).
  • The vote is one command: callvote map <map> <mode> <ruleset> [mods k=v ...]. The server takes only known keys (gravity, speed, jump, knockback, damage, health; vampire, regen, selfdamage, dash, grapple, instagib as 0 / 1), finite and in range; anything else is refused with the word that's wrong. mods with nothing after it = the standard rules.
  • Passed modifiers become a custom mode named voted (applyVotedRules, server.cpp): damage sets every weapon's damage multiplier, the rest are the custom mode keys of the same name. It goes to everyone through the existing EV_RULES path (no protocol change); clients say VOTED MODIFIERS ON or STANDARD RULES. A later map / mode vote without modifiers keeps them (the base mode follows the vote).
  • Refused: modifiers on a ranked or time trial server, on a server the host runs a custom mode on (the host's mode stays in charge), and tourney with modifiers (it plays its rulesets only).
  • Not in it yet: player size (no custom mode key for it) and weapon bullet limits (weapons have no ammo yet); both need a design call.
  • Test: quark_tests votes (testVoteModifiers): bad words refused, the modifiers applied on the server and in the shared rules, kept through a mode vote, cleared by mods. The UI test walks the builder's pages (ingame/pause/vote/setup/...).

Modes: ball (MODE_BALL 16, protocol 62; owner, 2026-11-01: "theres goals and you have 10 minutes to score the most goals, it should have the same physics as a player on ice at 50% gravity, use qk8 and the highway bridge map for it")

  • The ball is a 1 m box (kBallHalf), its centre sent in ModeState::bombPos (unused outside bomb, so the wire format only gains the mode number). ballMove (pmove.cpp) moves it like a player's hull with no input on an ice floor: half of pmove's gravity, the ground friction x kIceFriction (0.08) on any floor, 1 ms steps; from 10 m/s it slides 7.6 m in the first second and is at 5.7 m/s. A landing stops its fall like a player's; a wall or ceiling sends it back at half the speed (a player's hull stops dead, which would leave the ball stuck on walls).
  • What moves it (server, ballStep / ballBlast / ballShot, gamemodes.cpp): a player whose box meets it sends it on at 1.3 x the speed they meet it with, and lifts it 2 m/s; a blast pushes it like an enemy (the weapon's knockback x the falloff, twice the vertical), through blastReaches; a hitscan shot that meets it pushes it along the ray, 0.12 m/s per damage point, at most 12 m/s. Its speed is capped at 60 m/s. The grapple doesn't catch it yet. The ball is simulated by the server and drawn where the snapshot puts it (not predicted).
  • Goals: the map's two site boxes, each one the goal of the team whose spawns are nearer it. The ball's centre in one scores for the other team (teamScore; the last player to touch it is named, and gets a point if it's their own team's goal), and the ball goes back to the start: halfway between the goals, lifted out of anything solid and dropped to the floor (on QK8, on top of the block under the middle bridge). Into the void or lava: back to the start, no goal.
  • One 10 minute round (roundTime 600), 3 s respawns. The host's score limit ends it sooner. When the clock runs out the most goals wins; level is a draw ("match over").
  • Mode logic: tools/modes/ball.qlogic with a new object ball (needs the sites) and its step ballStep() (MA_BALL_STEP); the native rules match it (modelockstep case on QK8).
  • Maps: QK8 gets a goal in each end room's 12 m door (site -68 0 -6 -63 6 6 and its mirror) and offers ball; Suspension Bridge gets one across the deck just inside each tower (x +-84..88) and offers ball, and loses its fog banks and haze sheets (owner: "remove the smoke from the highway map"), so its long sight lines are back; spilled shipping containers (2.6 and 5.2 m tall, on the old banks' footprints at x +-19..22 and +-59..63) take the banks' place as cover, so no two enemy spawns see each other (the spawn check: 11 pairs in sight without them, 0 with). A Lodestone mode test now skips ball (no goals there) and counts winner as 1v1. The sites don't make either a bomb map: their modes lines don't list bomb. The menus start ball on QK8.
  • Bots: they get behind the ball on the far side from the enemy goal, then run through it.
  • Client: the goals as boxes in their team's colour (YOUR GOAL / ENEMY GOAL), the ball as three crossed boxes with a dark band that turns as it rolls, and the objective line.
  • Test: quark_tests ball (testBallMode): half gravity, the ice slide, the wall bounce, blast and shot pushes, a goal scoring for the other team and resetting the ball, the clock ending the match.

Movement: air drift 0 (protocol 63; owner, 2026-11-01: "change air drift to 0")

  • kAirDriftAccel 8 -> 0 m/s². Below run speed, a held direction in the air used to push you along it at 8 m/s² up to run speed (so a standing jump drifted like a run-up one). Now it adds nothing; strafe acceleration and air control are untouched. The air_drift multiplier in custom rules multiplies 0, so it does nothing now. Protocol 63, since an older client would predict the drift. Owner asked for no tests this push; the movement tests that measure drift (standing jump + W) will need their numbers updated.

Movement: dash slide (protocol 64; owner, 2026-11-01: "if you dash down into the ground, without holding space, it should put you on ice level of sliding until you go back to regular speed, or until you press jump")

  • A dash aimed down far enough for a super dash (the same kSuperDashDown test) that meets the floor with jump neither held nor buffered sets MoveState::dashSlide. Once the dash ends, the floor grips like ice while it lasts (friction x kIceFriction 0.08, push x kIceAccel), so the dash's exit speed carries you a long way. It ends when your horizontal speed is back to run speed (kGroundSpeed x the mode's speed) or when jump is held. With jump held or buffered, the super dash happens as before.
  • Wire: the own movement state's opt bit 128 (the dash bounces byte) is now sent while the slide lasts too, with bit 128 of that byte meaning the slide (bounces never reach 128), so prediction replays it. No tests were run, at the owner's request.

Modes: ball tuning (owner, 2026-11-01)

  • "treat every surface as if its rubber floor, bouncy": ballMove bounces off any face at kRubberBounce (0.85) of the speed into it when that's over kRubberMin (3 m/s), and stops a slower touch (it was walls x0.5 and no floor bounce). The ice glide on the floor stays.
  • "it shouldnt respawn instantly": after a goal it sits in the goal, and after the void or lava it stays where it went, for 3 s (Server::ballRespawnAt_), out of play (nothing pushes it, no second goal), then goes back to the middle.
  • "knife pushes too much, it should be more vertical and less horizontal": a knife swing on it gives 1.5 m/s along the swing's level aim and lifts it to 7 m/s up (it was 3.6 m/s along the aim).
  • "projectiles should interact with it": rockets, lobs and bubbles go off on it (their blast pushes it), a bolt, thrown knife or syringe gives it a quarter of its speed (at most 15 m/s) and stops, and a sticky gives it that and drops off.
  • "if the ball hits somebody moving more than 20m/s relative to them it should damage them and knock them back": (speed - 20) x 4 damage, at most 100, a knock of half its relative velocity (at least 4 m/s up), and the ball bounces off at half that speed. The kill goes to the last player who touched it (kill cause kDamageBall 248, "ball"). Once per player per 0.3 s.
  • "laser needs to do more knockback like 3x, and pull doesnt work": on the ball the laser pushes 3 x its push on a player (60 m/s per second of beam; it was damage-based, about 4.8), and the pull beam pulls it toward you as hard (it used to push).
  • The ball test waits out the respawn delay.

Modes: the grapple catches the ball (protocol 65; owner, 2026-11-01: "you should be able to hook the ball")

  • HookProbe gets the ball's box (server: where it is; client: the latest snapshot's, since the ball isn't predicted), and the hook catches it ahead of any wall: hookTarget kHookBall (kMaxPlayers + 2). You're pulled to it as you would be to a player, the server moves the anchor with it each tick, and it reels the ball toward your chest at 30 m/s² up to 20 m/s relative to you. It lets go at the usual release distance, on release, or when the ball leaves play (a goal or the void). The wire readers keep kHookBall from 65 on (older readers drop it). The ball test checks a grapple aimed at it catches it.

Weapons: rocket knockback x2 (protocol 66; owner, 2026-11-01: "rocket should do double knockback in all cases")

  • The rocket's knock (a blast's centre) goes from 11 to 22 m/s. It covers your own rocket jumps (x1.3 as before), enemies (vertical x2 as before), the lob, and the ball's blast push. Stickies and bubbles stay at 11. A weapon set's own knock still overrides it. Protocol 66, since rocket jumps are predicted.

Modes: one knife push on the ball per swing (owner, 2026-11-01: "okay so the knife got even crazier lol")

  • A swing that hits no player keeps cutting every tick for kKnifeSwingTime (0.12 s, so 120 hitscans at 1000 Hz), and each one pushed the ball, up to the 60 m/s cap. Now a player's knife pushes the ball at most once per 0.9 x its refire (Server::ballKnifeAt_): 1.5 m/s forward, 7 m/s up.
  • Ball damage (owner, 2026-11-01: "ball should only do 1 damage per m/s total"): the relative speed itself (21 to 60), no longer (speed - 20) x 4 up to 100. It still needs over 20 m/s.

Modes: the ball goes up ramps (owner, 2026-11-01: "balls should also try to go up ramps that are more than 45 and launch up still")

  • In ballMove, a slope's face (a wedge, corner or cut box, at any angle) is no longer a rubber wall. The ball turns along the face keeping its speed, carries the rest of the step along it, and flies off the top. A hard landing on a face (over kRubberMin into it) bounces like rubber about the face. Box faces stay rubber. The ball test rolls it at 20 m/s into a 63° 4 m wedge: it goes up to 8.6 m.

Website on quark.casa (owner, 2026-11-01: "help me link the website to the shit i have setup already")

  • The website (GitHub Pages, published from the site branch to gh-pages) gets the custom domain quark.casa: a CNAME file on the site branch, which the pages job copies onto gh-pages (the release-testing job keeps whatever gh-pages has, so it stays too). Once the domain is set, GitHub forwards every floration.github.io/quark URL to the same path on quark.casa, so links in older builds, Discord posts and the launcher keep working.
  • Linux and macOS HTTP requests (system curl) now follow redirects (-L), but only to https (--proto-redir =https). Why: before this, curl handed back GitHub's 301 page instead of the file, so the signed master.txt lookup at the old address would silently fail once it forwards. Windows (WinHTTP) already followed them. No effect today: master.txt names the built-in ranking address and has no signature, so every build falls back to that address anyway.
  • play.quark.casa names the official game-server VPS (production port 27500, testing 27501): players can type it in Connect instead of the IP.

Fullscreen size warning (owner, 2026-11-01: "the game should have a warning next to the fullscreen resolution setting")

  • VIDEO + AUDIO > MORE 3: a line in the WARNING colour under FULLSCREEN SIZE, "WARNING: DON'T TOUCH THIS UNLESS YOU KNOW WHAT YOU'RE DOING". A wrong exclusive mode can leave the screen black or stretched, so most players should leave it on DESKTOP.