# Changelog All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and versions use SemVer tags like v1.0.0. ## [Unreleased] ## [1.3.59] - 2026-10-18 ### Changed - The autopilot's reports name the two modes it keeps switching between, and where each was headed. ### Fixed - A burrowed autopilot no longer drives into a teleporter it cannot use, and lets Burrow go when its goal is somewhere a burrowed tank cannot reach. - An autopilot rocking back and forth in one place backs out and tries again. - An autopilot no longer fires into a teleporter whose shot comes back through it and kills him. - A tank could spawn half inside a wall and hang there, unable to fall or drive. - The anticheat no longer warns about a tank still sinking just after it dropped Burrow. ## [1.3.58] - 2026-10-17 ### Fixed - A burrowed autopilot no longer drives into walls, pyramid ribs and box corners he took for open ground, and turns onto a point at his own, slower, rate rather than circling it. - The anticheat no longer warns about a tank below ground after it lost Burrow while it is dead or climbing back out; one that stays down is still reported. ## [1.3.57] - 2026-10-17 ### Added - Discord (#193, see docs/discord.md): members of a Discord server link their bzflag.org account through Linked Roles, for a BZFlag player role and a BZFlag admin one, and get their callsign as their nickname there. - A server's public chat is mirrored to its Discord channel, marked `@` for admins and `+` for signed-in players, and the channel's messages come back into the game -- on bzo's Chat tab, and as server messages for BZFlag clients. - /help links the BZFlag Discord, and the account link where it is set up. ### Changed - On a screen narrower than 16:9, a phone held upright most of all, the third-person and observer Follow cameras come closer, and third person tilts up to show the tank and the way ahead rather than the ground behind. - A burrowed autopilot fires along the floor again at foes who can hop the shot, from one slot only, keeping two for a landing-shot pair. ### Fixed - A list-server report full of slashes could hold the server up for seconds. - A renderer error shown in the debug panel is text, not markup, and the flags table on /help escapes backslashes. ## [1.3.56] - 2026-10-16 ### Changed - With Shock Wave the autopilot drives at a foe rather than aiming, fires when the foe will be inside the sphere -- measured in three dimensions, as the wave hits -- and holds while a team mate could be caught, unless the server has team kills off. - Against a burrowed foe the autopilot jumps to land on him, including as the dodge to his shot, instead of hopping in place. - Burrowed, the autopilot no longer fires along the floor at a foe who can hop the shot; it keeps its landing shots. - Landing shots allow for a slow frame as well as lag, and where the window is narrower than the connection's jitter and a frame, two go, one either side of the moment. ### Fixed - The autopilot no longer tries to jump while burrowed, nor plans a route with jumps from under the ground. ## [1.3.55] - 2026-10-16 ### Changed - The autopilot plans its routes in a worker, so a slow device no longer freezes for seconds on its first route or on picking up Wings, and it keeps driving while it waits. Route graphs also build in about half the time. - The autopilot leads a landing shot by its own lag, so a laggy or slow client stops arriving after the tank has landed and driven off. - A disguise (Masquerade, Colorblindness, Seer) repaints the tanks it changes instead of rebuilding them. ### Fixed - The autopilot no longer kills itself with Trigger Happy: it turns the barrel off any heading whose shot would come back, and it and the bots dodge their own ricochets. - Textures and geometry no longer pile up over a session: a removed tank, explosion debris and the tank picker's preview are freed. A slow device's frame rate no longer falls the longer it plays. ## [1.3.54] - 2026-10-15 ### Changed - A tank is seen on the radar or out of the window, and lost only when both are gone: Jamming against Cloaking, or Blindness against Stealth. Bots and the autopilot do not chase, shoot or steer by a tank they cannot see, and identify and missile lock skip it. - Ace dodges an invisible bullet, which only the radar hides, unless it is blind. ## [1.3.53] - 2026-10-15 ### Added - The world viewer's summary (#183): pick nothing, or press World on the panel, for what a world holds -- counts by type, faces, textures by use, missing textures, the map file's warnings and its largest meshes, each of which shows that mesh. - The obstacle panel lists a box's or pyramid's wall and roof textures, and textures named by URL; names the materials it uses, with their blocks from the map file; and says what shape a mesh was made from (arc, cone...). ## [1.3.52] - 2026-10-15 ### Added - The world viewer's first step (#183): an observer right-clicks or taps an obstacle in any view, the Overview most of all, or presses `I` in the Free view, and sees it outlined, with a panel of what it is and its block from the map file, with the `group` blocks that placed it. - The map parser records where every object came from in its file, for the viewer and the editor to come; the world JSON is unchanged. - `/api/world//obstacles//source` answers one obstacle's block. ### Fixed - A server on a slow machine no longer logs its own boot as a `[LOOP]` stall: the watchdog counts from the event loop's first turn. ## [1.3.51] - 2026-10-14 ### Added - A link to a bzo server shows its icon, name and description in Teams, Slack, Discord and the like: the page carries Open Graph tags, written for the host it was asked for. - Search engines and AIs can find and describe every bzo server: its pages carry schema.org data -- the game, and on `/list` every public server -- `/help` is the controls and every flag as a page, and each server answers `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt`. The entry dialog says in a line what the game is, with links to help, the servers and the source. - `/apple-touch-icon.png` and `/apple-touch-icon-precomposed.png` answer at the site root, where Safari and link-preview crawlers look without reading the page. ### Fixed - `adminWhitelist` matches a link-local IPv6 peer: its address arrives with the interface it came in on (`fe80::1%ens18`), which made it match nothing, `fe80::/10` included. - Ace fires two landing shots at a foe holding Shield, which takes one hit for its holder; he fired one, and the foe landed with only its flag gone. ## [1.3.50] - 2026-10-14 ### Fixed - Ace dodges by driving whenever any drive clears: every speed and turn is tried against every shot in sight, judged by the hit a shot really makes, and the clear one nearest what he is doing is taken. Against a live player, most of the bots' deaths were landing shots on dodge jumps that a turn would have avoided; on random shots he now jumps half as often. - A jump Ace cannot avoid goes straight up with a turn on it that brings his sights onto the shooter as his muzzle comes down to the shooter's height, and he fires then, before the landing the shooter is waiting for. - Ace keeps his sights on a foe circling him: he steered by the angle off as a share of a turn, which left him a third of a radian behind a tank going round at speed, trailing it without a shot. He now turns onto where his shot would meet it, with its own sweep on top. ### Changed - The radar is half again as large wherever that leaves the targeting box, the altimeter and the reload bars clear, and the debug HUD below it is shortened to match. - `/countdown` also clears the scores held for players who left, so nobody rejoins into the new match with the last one's score, and each browser's own self-kill count and kills of and by each player. - A browser's one-on-one count with a player, and its own self-kill count, start over whenever that player's score does, as on a team change. - `/countdown ` counts down in chat before the match starts, as upstream's does, ten seconds by default where there is a clock, and `/countdown cancel` stops it. ## [1.3.49] - 2026-10-13 ### Added - Ace banks landing shots off walls: with ricochet on, a foe coming down out of sight is shot off one or two walls, timed to the touchdown. ### Fixed - Server bots shake off a bad flag after the shake timeout, as a player's client does; they only ever lost one by their kills. - Ace drives with Reversed Controls: he asks for the opposite of what he wants, and gets where he is going instead of backing into walls. - Ace dodges a shot only where it meets the path he is driving, by the speed nearest his own that clears every shot in sight; a jump is the last resort, since a tank in the air is the easiest one to hit next. - Ace's landing shots check the line at the muzzle's height: along the ground it grazed the foot of a wall and took a shot that would hit it. - Ace no longer shoots himself at point blank: a shot that bounces straight back off a wall or a pyramid a hand's breadth away is held, as one from further off already was. Orin did it three times in one game with Burrow. - A burrowed Ace has routes again: under the ground, no route started at all. ## [1.3.48] - 2026-10-13 ### Fixed - A paused tank keeps telling the server where it is, as upstream's does, so a long pause is no longer taken for a lost connection and its flag dropped; the HUD says Paused while it lasts. ### Changed - `GET /api/players` is public, for sites that poll servers: everything a client in the game is sent, now with each player's BZID, motto, team kills, autopilot, connect time and `/lagstats` figures, and never an address. Built at most twice a second, with CORS open. docs/api.md lists what a poller can ask a bzo server. - A player's connect time is kept across a restart, as scores are. - `/api/list-server/list` gives a BZFlag server's world variables as name to value rather than as pairs; `/list` reads either. ## [1.3.47] - 2026-10-12 ### Added - Ace answers a camper: a foe who kills twice in three minutes with Guided Missile, Laser or Super Bullet -- of Ace, as a rogue; of his side and three clear of every other tank on score, in team play. He hunts him wherever he is, keeps or goes for what beats his flag (Stealth against Guided Missile, Cloaking against Laser), and with the camper's flag all but spent under a shot limit he waits it out, then hunts him once it drops. Bots and the browser autopilot see the kill feed, every shot fired and the server's shot limits, as a player does. - Ace runs over a burrowed foe, whom his shells fly over, backing up when that is sooner than turning round: come down past him from a jump, he reverses over him instead of circling back. ### Fixed - A tank wedged into a wall while falling straight down is shoved out, as upstream shoves it, at a quarter of tank speed. The shove went at the tank's own speed across, which falling straight down is none, so it hung in the air for good: Orin, backed into hix's north-east corner. - Ace no longer asks to drop a bad flag, which only its shake countdown, a kill or an antidote takes off: the browser autopilot asked again every second and was told no each time. - Ace's landing shots aim inside the moment the foe comes down through the shot's height, not at its edge. From a burrowed tank's low muzzle that moment is a hundredth of a second, and a shot timed to its last instant arrived just as a hopping foe left the ground again. - Ace with Burrow no longer dodges tanks that cannot drive over him: one on a deck above, crossing his line, counted as a threat. Head on, with no way to drive clear, he fights rather than stand still trying to jump out of the ground, which upstream does not allow. - Ace as a rogue no longer goes after team flags: a capture is only made on the carrier's own base, which a rogue has none of. He plays for kills. ## [1.3.46] - 2026-10-12 ### Added - Ace plans Wings jumps that flap at the top of the arc, as many times as `_wingsJumpCount` allows: at 2, he goes from hix's floor to a base in one flight. ### Fixed - Ace no longer replans a route in the air, which could start it from a surface he was only passing over. - Ace's jumps no longer clip a roof edge with the back of the tank. - Ace's jumps land with Wings: he flies them straight at the speed he left with, where he used to brake and turn in the air and drop short. - A burrowed tank can no longer jump, as upstream: anything under z 0. ### Changed - `npm run bench:stuck` takes `--wings `, `_wingsJumpCount`; Wings never left the ground in it before. ## [1.3.45] - 2026-10-12 ### Fixed - Ace's jumps land where he planned while carrying High Speed or Thief; before, he flew half again past the landing. - Ace reaches a flag behind hix's corner teleporters, on the ground and on the upper level, instead of driving into the teleporter or its frame. ### Changed - `npm run bench:stuck` takes `--flag ` and `--to x,y,z`. ## [1.3.44] - 2026-10-12 ### Added - Per-flag shot limits, as bzfs's `-sl`: `shotLimits` in `server.json` or `-sl ` in a map. The last shot uses the flag up. Where any limit is set, the HUD shows "N left" or "unlimited" above the reload bars. - Scores survive a restart for new code (nodemon, a Docker upgrade), but not a map or server option change. - `docs/bzfs-options.md`: every bzfs option and what bzo has for it. ### Changed - Server bots take the highest free player slots and are named for them (`Ace41`), so a bot that leaves and comes back keeps its score. ### Fixed - A bot reaching a base no longer has its capture logged as "standing on no base". ## [1.3.43] - 2026-10-11 ### Added - Tank skins, as in BZFlag 2.5: Standard, Hex, Digital, Facet and Solid, chosen in the entry dialog or XR Player Options and changed without a respawn. ### Changed - Tanks wear upstream's texture for their team; only its team-coloured patches follow each player's shade. Free-for-all colours keep the repainted camo. ### Fixed - The turret's camo is no longer stretched, and the hull faces bzo fills in carry the pattern at upstream's size. ## [1.3.42] - 2026-10-11 ### Added - A player changes team without a rejoin, as BZFlag 2.5 does: between playing teams the tank keeps its life and score and takes the new team at its next spawn; going to observer self-destructs a live tank; to and from observer has no rejoin wait. ### Changed - Paused and not-responding tanks are dimmed on the radar, as upstream draws them. - hix's and bzo.bzw's walkway grid is see-through in BZFlag clients too. ### Fixed - Changing only the tank in XR Player Options no longer respawns it. ## [1.3.41] - 2026-10-11 ### Changed - The BZFlag port's TCP and UDP listen, and WebTransport (with `https` and its packages) and WebRTC (with werift) are on, without a `server.json` block asking: `"bzflag": false`, `"webtransport": false` and `"webrtc": false` turn each off and stop offering it. - hix lets tanks jump. - Ace, the autopilot and server bots, plays to the score: a foe coming down from a jump on his level within a shot's reach is shot first; a foe 10 clear of everyone is hunted ahead of captures; 3 under his foes' average, he fights rather than captures or fetches flags. - A map's flag and team spawn zones reach browsers and bots beside its safety zones. Ace takes an unnamed flag in a one-type zone for that type, and with no foe left alive waits within a shot of a foe's spawn zone. - Ace's routes no longer drive or fly through a wall thinner than the route grid (hix's diagonal walls), drive off the point of a turned base, or land where a small miss falls off a corner; he goes round on the ground rather than jump to a landing a foe has a clear level shot at, unless that is much further; and he no longer fires a level shot at a foe whose body is not at his muzzle's height, Super Bullet included. - `npm run bench:stuck` drives Ace between random spots on a map and lists where he backed off, missed a jump or never arrived. - Ace takes his own flag to the middle of his base and puts it down there on the move, rather than at the edge he came in by -- where he could also stall, turning between home and not. - Ace counts a jump flown once he is down on its level a few units short of the planned landing, instead of taking it a second time from there -- the second jump that carried him past his base onto the pyramid under it. - A jump from a flat mesh top with the tank's centre over its edge keeps its speed; it went straight up, as off a slope. - Ace gives up a flag he is held short of twice, and steers round a bad flag he knows of rather than drive over it -- including one the player he flies for has seen carried, identified or named. - Ace keeps Identify, remembers what it names, and trades it only for a flag worth it (L, GM, SB, SW, G, ST); he goes out of his way for one only while ahead on his own and his team's score. Server bots holding it learn what it names, as a person's client does. ### Fixed - Flags no longer spawn inside buildings on a map whose flags are 0 tall (hix), and one dropped inside a building climbs onto it or, with no top to stand on, leaves, as upstream. ## [1.3.40] - 2026-10-10 ### Added - Everything on one port, 5154 by default, TCP and UDP. Browsers carry moves and shots over WebTransport (`"webtransport": true`), else WebRTC (`"webrtc": {}`), with the voice TURN relays as the last resort and the WebSocket until one opens -- iOS Safari, Firefox, Chrome and the Quest included. BZFlag clients share the same UDP. - HTTPS on the port speaks HTTP/2, so a page's requests share one connection. - `/playerlist`, the scoreboard's `T` column and `/api/players` say which path a player is on: `h3+`, `rtc+`, `turn+` (`h`, `r`, `t`), and a BZFlag client's `udp+` (`b+`). - `/test.html` links the `?udp=h3|rtc|turn|ws` test modes and `/wt-test.html`; the log notes a main thread that stops answering. ### Changed - The default port is 5154, BZFlag's IANA port. - WebRTC no longer needs a port of its own: `webrtc.listen` and 5153 are gone, and node-datachannel with them; an old `listen` is ignored with a log line. ### Fixed - A BZFlag client on the server's own network gets its UDP link through the router's loop-back. - The scoreboard's names and 1-on-1 column show in Firefox. ## [1.3.39] - 2026-10-10 ### Fixed - Playing on a watched BZFlag server: Player Options now offers the teams that server runs, and /list's **Play** uses the team chosen there instead of always Automatic. A team the server does not run places the player rather than leaving them watching. ## [1.3.38] - 2026-10-10 ### Fixed - Ace no longer holds its own flag on a corner of its base without putting it down, sits stuck with Left or Right Turn Only or Reverse Only, or camps at a foe on another level that a level shot cannot reach. A camp that never fires is now given up. ## [1.3.37] - 2026-10-10 ### Fixed - Ace no longer gets stuck inside a lift's teleporter going straight back down, falls off a walkway after a lift, circles a flag instead of picking it up, trades one good flag for the next, rides a lift up and down chasing a flag, or camps at a foe its barrel cannot see. - Ace drops Oscillation Overthruster before a lift up, and no longer tries to drop its flag over a raised base it cannot get onto. ### Changed - Ace comes into a lift at the angle that leaves it facing the way on, and with a laser keeps just outside a foe's lunge-shot reach. ## [1.3.36] - 2026-10-10 ### Added - Ace camps: with nobody near it fetches flags until it has GM, L or SB, then shoots from the best vantage point it can reach, leaving a spot after shots it had to dodge or a Shock Wave in reach. Debug Geometry shows the spots it weighed. - Routes go through teleporters, so a pilot uses lifts such as hix's corners. Server bots now teleport at all, and lock a Guided Missile before firing. - A map's safety zones reach the browser, as they always reached BZFlag clients. hix has one on each base. ### Changed - Ace keeps a team flag until home, fighting on the way rather than dropping it, and drops its own flag over an enemy base when it would land near home. - Ace fetches flags on other levels by route, and plans routes with the jumping its own flag allows. - hix has jumping off for now, while the lifts are tested. ## [1.3.35] - 2026-10-10 ### Added - `publicUrl` may be a list of a server's names. The bzo list checks each one against the server's key and shows those that answer under **Also at**, and under **Carried by** on a carried server's row. ### Changed - Ace goes after whoever has its team's flag at any range, fetches its own flag home before an enemy flag that is not much nearer, shoots back at a foe that just shot at it, counts a runaway score leader as nearer, keeps to captures when its team is behind, and hunts rather than wanders while there is anyone to find. - The autopilot's HUD line and server bots' debug labels say why Ace picked its target: `chase (retaliate)`, `hunt (ours-carrier)`, `chase (leader)`. - Release and CI images come from mirror.gcr.io rather than Docker Hub. ## [1.3.34] - 2026-10-09 ### Added - PROXY protocol, v1 and v2, on the listen port: a TCP proxy such as HAProxy (`send-proxy-v2`) listed in `proxyProtocolFrom` names each client, for the web app, HTTPS and BZFlag alike. A header from anyone else is refused. See **Behind a TCP proxy** in docs/installation.md. ## [1.3.33] - 2026-10-09 ### Fixed - A proxy reaching the server from a second address, such as its other IP family, could make every client behind it admin when that address was in `adminWhitelist`. Forwarding headers from anyone but the proxy the startup probe came through now never grant admin. ## [1.3.32] - 2026-10-09 ### Changed - One port: with a `bzflag` block, `listen` is the BZFlag port too, TCP and UDP, and also serves the web app and HTTPS. `bzflag.listen` and `"listen": false` are gone: move the BZFlag port to `"listen": "[::]:5154"`. `:::5154` means the same. docs/installation.md has a new Ports section. - IPv4 BZFlag clients on a dual-stack port show and are banned by their plain address. - A direct connection from an `adminWhitelist` address is admin, not only one through the proxy. ### Fixed - A client connecting to the BZFlag port directly could claim a whitelisted address in `X-Forwarded-For` and get admin. Forwarding headers are now read only from the proxy the startup probe came through. - A nearly opaque texture no longer lets far walls show through near ones. ## [1.3.31] - 2026-10-08 ### Added - One TCP port: the BZFlag port answers the web app too, plain HTTP or HTTPS (`https` in server.json), chosen by what a connection opens with; Express serves it the same either way. `"listen": false` turns the separate web port off. Closes #190; docs/port-mux-plan.md has the UDP half (#189). ### Changed - A tank's shot now follows the move it was fired from, for everyone: the browser sends its move before every shot, as BZFlag's client does, and the server relays the move ahead of the shot. - A tank starting to creep, or one axis stopping while the other carries on, is sent at once rather than at the next heartbeat. ## [1.3.30] - 2026-10-07 ### Fixed - A tank driving on the ground jumped about on BZFlag clients' screens: bzo told them its velocity was zero, so they held it still between updates. - `/mv`'s "Nowhere to stand" named the wrong coordinate. ## [1.3.29] - 2026-10-07 ### Added - Observers pitch and strafe: `Shift` with drive tilts the roaming camera up and down, with turn slides it sideways; travel stays level. Upstream's Ctrl and Alt jobs, on the one modifier a browser can spare. See #33. - A Share View Link carries the pitch of a tilted view as a fifth `pos=` value, and opens tilted. - `/mv` moves an observer's camera, into free roam, taking that pitch. - `/setteam `: an operator moves a player between playing teams in place. A BZFlag 2.4 client takes the team too. - Chat history can be selected and copied while chat is open. ### Changed - `/mv` tells whoever was moved who did it. ### Fixed - `Ctrl+C` on a chat selection changed the camera. ## [1.3.28] - 2026-10-07 ### Added - Moves and shots over WebRTC (`webrtc.listen`): a data channel that neither orders nor retries, on one IPv4 UDP port, carrying what bzfs sends over UDP -- moves, shots, shot ends, missile updates. A lost packet costs one move instead of stalling the WebSocket, and moves are relayed as they arrive rather than at the 16ms tick: 1.8ms against the WebSocket's 11.7ms, bzfs's own figure. Players whose channel does not open play as before; `/playerlist` shows ` udp+` for those whose did. Closes #8. - `scripts/bench-moves.mjs`: move latency between bzo clients, over the WebSocket or the channel. ## [1.3.27] - 2026-10-07 ### Added - `bzflag.upnp`: bzfs's `-UPnP`. The router forwards the BZFlag port, TCP and UDP, to this server. Without a `publicAddr` the list gets the router's reverse-DNS name where it resolves back, else its address. Closes #187. - A `[PERF]` line in server.log each minute while anyone plays (CPU, event-loop delay, messages and bytes each way), also at the end of `/lagstats` for an admin; `scripts/bench-relay.mjs` compares relay latency and CPU against bzfs. ### Changed - BZFlag clients get each move as it is accepted, as bzfs relays one, rather than at the next 16ms tick, and their frames leave in one write per turn of the event loop: half the latency and half the CPU per update. ### Fixed - A proxied player's updates stayed on TCP: the session dropped bzfs's MsgUDPLinkEstablished while waiting for the game settings. ## [1.3.26] - 2026-10-07 ### Added - A `T` scoreboard column after the player number: what each player is playing on -- `d` desktop, `m` mobile, `v` in VR or on a headset's browser, `b` a BZFlag client, `r` a robot, `s` a server bot. A proxied server's players are `b`, or `r` for its robots (#185). ### Fixed - A robot from a BZFlag client joins as a robot rather than as a person. ## [1.3.25] - 2026-10-06 ### Changed - bzo's world is in BZFlag's own coordinates throughout -- +Z up, +Y north, headings as azimuths counter-clockwise from east -- in its data, its wire, its simulation and its renderer, so the proxy and a native client's moves pass positions and headings through unconverted (#182). A world file cached by an older server is rebuilt. - A box's or pyramid's roof texture faces as BZFlag's does, a half turn from before. ### Fixed - Teleporters were drawn mirrored; each portal now faces out of its own side. - The collar a shot wears through a teleporter points along the shot. - A phasing tank's cut through a wall is in the right place in VR. - A Narrow tank's own hit test turns with the tank. ## [1.3.24] - 2026-10-06 ### Fixed - A tank that dies with Narrow, Tiny, Obesity or Thief no longer explodes at that size: its wreckage grows back to the tank's own size as the flag drops, as BZFlag's does. A Narrow tank's hull used to tumble as a flat sheet (#181). - `proxy-addr` 2.0.8, for its IPv6 trust-subnet spoofing advisory. bzo does not use Express's `trust proxy`, so it was not exposed. ## [1.3.23] - 2026-10-06 ### Added - Gamepad and VR controller rumble, as BZFlag 2.4.30 does: on firing (longer for a shock wave, both motors for a laser) and on death. Only for the active player, never an observer, and only while the controller is the input in use; Settings has a Rumble toggle, on by default (#179). ### Changed - `WA` Wide Angle is never spawned: bzo gives it no effect, so a map's `+f WA` or `zoneflag WA` is skipped like any other forbidden flag. ### Fixed - A proxied or watched player's first flag list says which flags are zoned, as the later updates already did. ## [1.3.22] - 2026-10-05 ### Fixed - Releases build again. The CI and release jobs that run in the `ubuntu:26.04` container failed on git's ownership check, so v1.3.20 and v1.3.21 published no images; their changes ship in this release. ## [1.3.21] - 2026-10-05 ### Added - `docs/bzflag-2.6-plan.md`: serving BZFlag 2.6 native clients alongside 2.4, what to suggest upstream (clients send their version string, `BZFS0226`, in place of `BZFLAG`; the list server keys entries on host:port and version), and what bzo already does that 2.4 does not. ### Changed - The Docker image keeps each texture, sound and model at the time of its last commit, so after an upgrade a browser revalidates an unchanged one and gets a 304 instead of downloading it again (#180). ## [1.3.20] - 2026-10-05 ### Changed - Node.js is Ubuntu 26.04's own `nodejs` package, 22.22.1 today, in the Docker image and in CI, as on a server running from source. The image carried the nodejs.org 24.19.0 build before. ## [1.3.19] - 2026-10-05 ### Changed - Node 18 and Ubuntu 24.04 are dropped. CI runs on `ubuntu-26.04`. ## [1.3.18] - 2026-10-05 ### Added - [docs/bzfs-web-plan.md](docs/bzfs-web-plan.md): a plan for bzfs to serve the web client and take browsers as players on its own game port. ### Fixed - A dead tank stays where it died until it respawns, autopilot or not, so its radar marker no longer drives around. Observers and Map Viewers still move. #177 ## [1.3.17] - 2026-10-05 ### Added - Replays: `/?replay=` plays a bzfs recording to everyone watching it, with the live viewers under "Watching" on the scoreboard. "Local replays" on `/list` and in the View dialog, upload and delete in the Operator panel, and `/replay stats` and `/replay list`. See [docs/replay.md](docs/replay.md). #172 - Recording: bzo keeps its last game in a buffer from boot, and `/record` or the Operator panel's Save Recording writes it as a bzfs recording. #172 - The Operator panel deletes the maps it uploaded. ## [1.3.16] - 2026-10-05 ### Added - `voiceIceServersIpv4`: the relay by names with no IPv6 address, for the clients that keep voice on IPv4. See **Voice** in [docs/installation.md](docs/installation.md). #8 - [webrtc-test.html](public/webrtc-test.html): a WebRTC call inside one page, through a chosen relay or on the device alone, that says when the device loses its internet. More voice test modes on test.html. #8 ### Fixed - Voice on a phone over Verizon cellular no longer drops the phone's data connection: phones keep voice on IPv4, where a call over the phone's IPv6 made Verizon end its data session about a second in. #8 ## [1.3.15] - 2026-10-05 ### Added - Voice test modes on [test.html](public/test.html): no voice, relay only, silent, no STUN/TURN, Google STUN, no host addresses, and one peer. #8 - The debug log shows each voice peer's gathered candidates, the network a link uses, and STUN/TURN errors. #8 ### Fixed - A player whose connection drops and comes straight back keeps their name: the reconnect replaces the stale connection at once rather than waiting for it to time out, and a handed-out `Player n` no longer overwrites the saved name. - Voice no longer tries to connect to server bots. - TURN credentials are unique per connection, so a dropped connection's leftover relays no longer count against the next player with its number. ## [1.3.14] - 2026-10-04 ### Added - Voice can use a coturn TURN relay: set `voiceTurnSecret` to its `static-auth-secret` and each player gets their own credential, valid for 24 hours. See **Voice** in [docs/installation.md](docs/installation.md). #8 ### Changed - server.json is saved with its keys sorted, and the JSON files bzo writes end in a newline. ### Fixed - Ban duration parsing no longer backtracks exponentially on crafted input. ## [1.3.13] - 2026-10-04 ### Fixed - The admin whitelist works behind a reverse proxy on a cloud host that NATs its public address, such as Azure: the server counts the addresses its public URL resolves to as its own. Its public address lookups no longer fail on Node's decompression, and a failed one is logged. ## [1.3.12] - 2026-10-04 ### Changed - The stock BZFlag tank has tracks and road wheels, and is the default. The tank without them is BZFlag notracks. #176 - A new ship model (PR #127), and Bzship 4k and 8k: the same ship at 4,000 and 8,000 triangles, for comparison. ### Fixed - The road wheels on a tank's left side turn the right way. #176 ## [1.3.11] - 2026-10-04 ### Added - A bots column on `/list`, for bzo servers and for BZFlag servers, and a `bc` filter on it. #173 ### Changed - The server uses far less memory. Bot routes are stored compactly, and only the live map keeps its world in memory and on disk. Other maps are listed from the map index and converted when someone views one. - Viewing another map, local or a remote server's, no longer stalls the game: the map is converted on a worker thread. - Server bots think and drive on a worker thread, started with the first bot and ended with the last, so planning a route no longer stalls the game. - `/list` builds its tables at most every 30 seconds instead of on every visit. - The Docker image and `compose.yml` open port 5154, TCP and UDP, for BZFlag clients. ### Fixed - A shot passes over a burrowed tank, as upstream's does, rather than hitting it. - bzo's BZFlag ping counts its bots, as bzfs's does. - Server bots keep sending a heartbeat while nobody is connected, rather than being marked not responding. ## [1.3.10] - 2026-10-03 ### Added - Address bans, as on a BZFlag server: `/ban` a player, an address or a block (IPv4 or IPv6, CIDR or `1.2.*.*`), `/banlist`, `/checkip`, `/unban`, and `/poll ban`. Behind a proxy, only an address its startup check vouches for is banned. #174 ### Fixed - Game sound no longer pops and drops out for a moment when an explosion lands on top of other sounds: a limiter holds the mix under full scale. ### Changed - The startup check of a reverse proxy also requires it to name this server for the server's own request, so a second proxy in front -- a CDN -- is not trusted with players' addresses. ## [1.3.9] - 2026-10-03 ### Added - A player who leaves with a score has it back on rejoining from the same address within `_scoreSaveTime` seconds, 120 by default, as servers running allejo's ScoreRestorer plugin do. - A map's `-set noWalls 1` and `-set freeCtfSpawns 1` do what the `world` block's own words do, and `/set _disableBots` takes effect at once. `/list` counts all three as read. - `_disallowSelfCap` and `_delayTeamFlagGrab`, as servers running allejo's ctfOverseer plugin set them: no capturing your own flag, and a captured team flag out of enemy reach for that many seconds. Off unless a map or `/set` turns them on; `/set` finds `_scoreSaveTime` too. - Polls, as on a BZFlag server: `/poll kick`, `/poll flagreset`, `/poll kill` and `/poll set`, `/vote` and `/veto`, with bzfs's own settings in server.json's `poll` block. Ban polls wait on address bans. - `/kick`, and BZID bans: `/idban`, `/idunban`, `/idbanlist`, kept in `bans.json`. A kicked or banned browser waits for its player before rejoining. - Menus play BZFlag's menu sounds: the flag-grab sound on choosing an item and the flag-drop sound on backing out, in XR too. - When bzo drops a player -- a restart, a shutdown, or its account signing in elsewhere -- every client, browser or BZFlag, gets the reason as a server message and then "Server forced a disconnect", as bzfs's MsgSuperKill, instead of a lost link. #174 ### Changed - HiX's bad flags fall off after 5 seconds or one kill, as on its reference bzfs. - A shot hits a tank where it would in BZFlag: within `0.99 * _tankRadius` (4.3 units) of the tank's middle, rather than bzo's own 2. Tanks are easier to hit, and BZFlag clients and browsers now agree on what is a hit; running over with Steamroller or Burrow uses the same radius. #174 ### Fixed - Ace no longer fires at a tank its shot cannot reach. Roger still does, as BZFlag's autopilot does. - Ace, holding still, lunges at a still foe just out of reach: a moment at full speed so the shot carries the tank's speed, then a stop -- base to base on HiX. - Rapid Fire, Machine Gun, Thief and Laser shots reach BZFlag clients at their real speed: `shotBegin` now carries the fired velocity as BZFlag's `MsgShotBegin` does, and each screen applies the flag, rather than bzo applying it before sending and a BZFlag client applying it again. #174 - Phantom Zone carries between bzo and BZFlag clients, and through the proxy, both ways: a zoned tank is zoned on every screen and to the server. #174 - A BZFlag client holding a bad flag uses its own antidote spot, rather than also shedding the flag on an unseen one of bzo's. #174 - A death-touch says what killed you on every screen, a BZFlag client's and a proxied bzfs's included, rather than "shot" or "Unknown Deadly Obstacle"; and a Genocide kill a BZFlag client reports takes its team. #174 - BZFlag clients are no longer told that some flag effects may differ. #174 - `/playerlist` shows a BZFlag client's UDP link as bzfs does (`udp`, `udp+`), and any player may see their own line. #174 - Scores agree everywhere. A BZFlag client is sent each kill's scores, as bzfs sends them, and bzo's browsers tally with the server's own rule, so a team kill, a self-destruct and a rabbit kill count the same on every scoreboard. A new match clears team kills too. #174 ## [1.3.8] - 2026-10-03 ### Changed - A map that names its own flags (`+f`, `-s`, `+s`) gets only those, not the server's random superflags too; one that names none, a random world included, takes server.json's `superFlags` and new `requiredFlags`. - A random map is now bzfs's own generated world: an 800-unit city with teleporters, or, with team mode on, the capture-the-flag world with a base for each colour. `_worldSize` and a `randomWorld` block in server.json tune it as bzfs's `-worldsize`, `-density`, `-t`, `-h`, `-cr` and `-b` do. On top of upstream's city, shapes vary in size (`randomSizes`), a fifth float off the ground and a tenth of the pyramids stand upside down (`floating`, `upsideDown`). ### Added - BZFlag clients grab, drop and capture flags, with Identify and guided missile locks. #174 - BZFlag clients' teleports and pauses reach bzo, and bzo's reach them: a paused BZFlag tank can no longer be shot, and browsers draw its teleports. #174 - BZFlag clients see team scores, and who won when the match ends. #174 - bzo's guided missiles steer on a BZFlag client's screen. #174 - BZFlag clients' lag is measured, and a random map can be sent to them. #174 - BZFlag clients use UDP for movement and shots once the link is up, as with bzfs, and `/mv` moves them. #174 - `/clientquery` lists every player's client version, and a BZFlag client used by an admin gets players' addresses for its scoreboard. #174 - Autopilot shows across both: `[auto]` on a BZFlag scoreboard for bzo players on autopilot and every server bot, and BZFlag's Roger on bzo's. #174 - A playing tank unheard from for `_notRespondingTime` is marked `[nr]` on the scoreboard, drops its flag where it was last seen, gives up the rabbit and cannot be locked on, until its next move, as in BZFlag. ### Fixed - A tank carrying Obesity, Tiny or Thief fires from the end of its resized barrel, as upstream's does, and the server expects it there. Narrow does not move the muzzle. ## [1.3.7] - 2026-10-03 ### Added - BZFlag clients can play in bzo: they join on the team they pick, spawn where bzo puts them, drive, shoot and die, with either side's word on a hit taken. #174 - BZFlag clients see bzo's N/S/E/W compass letters above the walls, and on their radar, with N in red. #174 - `/list`'s filter boxes have an x to clear them. ### Added - `/list` filters by version: `bzo-` lists bzo servers, and `/ve)2.4.*` a bzfs build. #174 ## [1.3.6] - 2026-10-03 ### Changed - On a server with a `bzflag` listener, bzo's clients send their idle update every second, as BZFlag clients do, so a BZFlag observer sees resting bzo tanks kept current. #174 ## [1.3.5] - 2026-10-03 ### Added - `/list` shows a BZFlag server's build, read with `/serverquery` on the world tracker's visit, and a bzo row's as `bzo--`. ## [1.3.4] - 2026-10-03 ### Fixed - A server that has just restarted no longer drops off `/list` for 15 minutes. It waits until it can answer before announcing itself to either list, sends one report rather than two, and the list server retries a failed check after 30 seconds, 2 minutes and 10 minutes. BZFlag list ADDs go one at a time, as bzfs sends them. ## [1.3.3] - 2026-10-03 ### Added - The BZFlag list shows which map bzo is running, after the title: ` - bzo` on bzo.bzw. #174 ### Changed - One `title` in server.json says what a server is: the entry dialog, both server lists and the Operator panel. `serverName` and the `bzflag` block's `publicTitle` are still read as it, and `description` is no longer shown; the startup log names the keys to rename. ## [1.3.2] - 2026-10-03 ### Added - The world a BZFlag client downloads is compiled by bzo itself, the same bytes bzfs would send, so a server needs no bzfs installed. #174 - A BZFlag client downloads the world over HTTPS from bzo instead of a kilobyte at a time, and keeps its clock in step with the server's, as bzfs's clients do. #174 ### Changed - bzo reports itself as `bzo-<release>-<build>` on the BZFlag list and in `/serverquery`, which now answers in bzfs's words. #174 ## [1.3.1] - 2026-10-03 ### Added - A BZFlag client's global login is checked when it joins, so a registered player gets their name, BZID and admin groups as a browser login does, and the scoreboard's `+` and `@`. A BZFlag client sees every player under the same number bzo's own scoreboard shows. #174 ## [1.3.0] - 2026-10-03 ### Added - A `bzflag` block in server.json opens a bzfs-style port and lists the server on the BZFlag list, so BZFlag clients can see it and join it to watch. The world they download comes from bzfs's `-cacheout`, so this needs bzfs installed and a `.bzw` map. See [docs/bzflag-clients.md](docs/bzflag-clients.md). #174 ## [1.2.105] - 2026-10-03 ### Added - The autopilot's chase target gets the lock-on bracket, as upstream's autopilot marks its target. Closes #170 - With debug labels on, a server bot's name label shows what it is doing and at whom (`Ace1 · chase Tim`). An observer following a server bot with debug geometry on sees its route, target and planned shot drawn over the world. - A zone's `flag <type>` makes flags of that type spawn and respawn in it, and `+f <type>[{n}]` keeps flags of a type always in the world. `bzo.bzw` has a `flag SW` zone for two `+f SW` flags. - The server list shows how many of a server's world variables bzo reads, `8/14 set`, and a tap opens the list with read and ignored ones in different colours. - More world variables are read: `_friction` and `_momentumFriction`, `_angleTolerance`, `_disableSpeedChecks`, `_hideTeamFlagsOnRadar` and `_hideFlagsOnRadar`, `_forbidHunting`, `_drawSky`, `_fogNoSky` and `_syncLocation`. A fogged map's sky now fades into the fog, as upstream's does. - `_drawGroundLights` turns off the light pools on the ground, and `_useLineRain` draws rain as upstream's line streaks, tinted by `_rainBaseColor` and `_rainTopColor`. `_endShotDetection`, `_coldetDepth`, `_coldetElements` and `_cullDepth` are read and have nothing to change. - [docs/bzdb.md](docs/bzdb.md) lists every world variable bzo reads, with its default and what it does. ## [1.2.104] - 2026-10-03 ### Added - The sky is upstream's: the real sun, moon and stars over the world's `_latitude` and `_longitude` at the real time, or the instant `_syncTime` holds -- a sky gradient with a sunset band, 400 stars turning overhead, and a moon in its real phase. Only the sun casts shadows. `"sky": "minecraft"` in server.json keeps the older day clock. Closes #166 - In Rabbit Chase, Ace hunts the rabbit wherever it is on the map. As the rabbit, Ace turns on the nearest tank closing on it and otherwise runs from the hunters near it. - On a server that allows several shots, Ace keeps a quarter of them back for a foe that gets close or a landing shot, instead of firing them all at range. ### Fixed - Chat to a proxied BZFlag server longer than its 127-byte limit is sent as several lines broken between words, instead of being cut off. Closes #169 ## [1.2.103] - 2026-10-03 ### Added - The server warns at boot when `adminWhitelist` names addresses but `localAdmin` is off, since the whitelist then grants nothing. ### Fixed - The proxy's token probe sets its plain-text type outright and forbids content sniffing. ## [1.2.102] - 2026-10-02 ### Fixed - A flag's fade and warp-in effects are freed when they end, instead of piling up hidden for every flag that ever respawned. - The proxy's token probe answers in plain text, so a remote server's words cannot be read as markup. - brace-expansion updated in the development tools for a CPU denial of service advisory. ### Added - `renderer.stats` lines name the commonest scene objects (`objectKinds`), so a count that climbs says what is climbing. ## [1.2.101] - 2026-10-02 ### Added - A `group` spun off vertical or sheared tips the meshes it places, and a spinning mesh inside it spins about the tipped axis, as upstream does. Helicopter tail rotors spin sideways now. - The guided missile is drawn as upstream's modelled missile, its nose and fins in the shooter's colour, sized by `_gmSize`, trailing upstream's densest smoke. - Meshes a map repeats at other places and facings are drawn as one shared shape, one draw call for all its copies; a player is told how many on entry. - The radar draws the map's still obstacles from one cached image while the player holds a height, and live while they climb or fall. - World variables `_lockOnAngle`, `_identifyRange`, `_rainPuddleColor` and `_disableBots` are read from a map. ### Fixed - A world weapon's guided missile flies until it hits something, as upstream's does, instead of stopping at an ordinary missile's range. - Shots are upstream's colours and size: a team's colour lifted only slightly toward white, so a blue shot is deep blue rather than pale, on a bolt two units across. - Watching or playing a BZFlag server without a login takes a random `bzo-view-` callsign, so two viewers no longer refuse each other. - A free camera opened from a link with `pos=` stays where the link put it, outside the map's walls included. - Watching a BZFlag server, a shot that flies over the outer wall bursts at the world's edge, as upstream's does, instead of flying on out of the world. ## [1.2.100] - 2026-10-02 ### Added - `_mirror`: a world can have a mirror ground, the world reflected in it under the mirror's colour. - Spawns follow upstream's SpawnPolicy: a tank does not spawn in front of a Laser, inside a Shock Wave's reach, beside a Steamroller or Burrow tank, or under a nearby tank's gun, and spawns as far from enemies as it can. `_spawnSafeRadMod`, `_spawnSafeSRMod`, `_spawnSafeSWMod` and `_spawnMaxCompTime` tune it. - More world variables: `_flagAltitude`, `_flagPoleSize`, `_pauseDropTime`, `_speedChecksLogOnly`, `_updateThrottleRate` and `_forbidMarkers`. - A world weapon's `color` sets the team its shots are fired for and drawn in. - server.json's `puffClouds` brings back bzo's own puff clouds. ### Changed - Clouds are upstream's single flat cloud layer, drawn behind the world (#165). - Lasers look like upstream's, textured and tinted in the shooter's colour, or the weapon's team colour (#159). - A client sends up to `_updateThrottleRate` (30) position updates a second, as upstream's does, rather than 10. ### Fixed - Server bots see other tanks again, so they chase, fight and fire (#167). - An observer stays an observer when the server restarts: the entry dialog's team choice is kept across reloads (#163). - Switching to observer lands the autopilot, and the scoreboard no longer shows it; it takes over again when you play (#164). - Clouds were drawn on the ground on the live map (#165). - A watched server's world-weapon lasers showed in the wrong colour (#159). ## [1.2.99] - 2026-10-02 ### Added - `/set` and `/reset` take any world variable by upstream's name, formulas included (`/set _tankSpeed 40`, `/reset *`), tell everyone in upstream's words, and take effect live for every player. - server.json has a `bzdb` block: upstream's command-line `-set`, by upstream's names (`"_tankSpeed": "30"`). The old keys (`tankSpeed`, `gravity`, `shotDuration` and the rest) still work, and the startup log names each one to move. See docs/installation.md. - Maps, server.json and `/set` now reach many more of upstream's variables: the tank's size; every superflag's tuning (Machine Gun, Laser, Shock Wave, Guided Missile, Burrow, Rapid Fire, Thief, Velocity, Quick Turn, Tiny, Obesity, Narrow, Agility, Steamroller); Wings; `_noClimb`; wall, flag and default box heights; explosions; fog and sky colour; `_syncTime`; whether a world draws mountains, clouds, sun and moon, ground and shadows; tread mark fade; and the radar limit and `-noradar`. `docs/bzw.md` lists them. - `-set` values may be formulas (`12*_muzzleHeight`), and upstream defaults that are formulas follow what they are built from, so `_tankLength 5` shrinks the tank's radius, muzzle and explosion too. - Colours take upstream's X11 names (`black`, `grey3`, `red 0.5`), in materials as well as variables. - The radar draws upstream's height box round every tank, bigger the higher it is. - server.json's `timeOfDay` and `daySpeed` set the sky clock (#4). - `/list` filters by `+guestWatch`, `+guests`, `+guestChat` and `+bots`, and shows Guests Watch / No Guests. - A player who leaves and comes back within `_rejoinTime` waits out the rest before spawning, as on upstream. ### Changed - `_explodeTime` is the respawn delay, as upstream has it; bundled maps that set it now respawn that fast. - The radar's ranges are upstream's: a share of the world's radar limit (the world size by default), so Medium shows half the world. - Fog is its own colour, `_fogColor`, rather than the sky's. - A jump from a slope goes straight up unless the world sets `_noClimb 0`. - A Map Viewer preview plays the map as it would play on this server, server.json's `-set`s included. ### Fixed - `_disableBots=true`, which upstream's `-disableBots` sets, counted as allowing bots, on `/list` and through the proxy. - The server's sky clock ran about three times fast, so joiners saw a different time of day from everyone else. - A `box` or `pyramid` with no `size` line had no size at all. ## [1.2.98] - 2026-10-02 ### Added - Autopilot in menus (#162): with a pilot chosen in Settings -> Autopilot, opening a menu hands the tank to the pilot instead of pausing, and closing it takes the tank back. With `None`, or where the server refuses bots, a menu pauses as before. - Guest access on `/list`: bzo checks whether a server lets unregistered players chat and spawn, and shows **Guests Play** / **Registered Only** and **Guests Chat** / **Guests Muted**. Admins can play there as `bzo-<callsign>` where guests may spawn. See docs/proxy.md, "Guest access". - `/list` has a footer naming bzo, its version, license and source. - docs/controls.md has every control: keyboard, mouse, touch, gamepad and XR. ### Changed - Ace lands facing where he is going, jumps onto buildings from where he will make it, drops a flag only while moving, and lets go of Oscillation Overthruster before landing on a building. - Ace aims from the air and at tanks in the air, and paces his speed to time the shot; jumping onto a guarded flag he turns to face the tank and fires on the way up. - Ace with Burrow no longer dodges shots by jumping out of the ground; he moves only to keep from being driven over. - Ace takes fewer waypoints, drives straight lines, and keeps clear of building edges and support bases; base-to-base runs on hix are about 20% faster. - Server bots drive, fire and collide with the same code as a player's tank and the autopilot, so every Ace improvement reaches them too. - Shells and lasers are traced by the same code on the server and in the browser, through teleporters, off buildings and out at the world's edge. ### Fixed - Autopiloted tanks look smooth to other players instead of jerky. - A shot fired from the top of a box is no longer refused as too fast. - The world's border walls follow a map change that happens mid-game. ## [1.2.97] - 2026-10-01 ### Added - Autopilot (#151): `9`, `/autopilot [roger|ace]`, or Settings -> Autopilot hands the tank to a pilot. Roger is BZFlag's own autopilot; Ace is Roger with improvements -- he remembers flag types, goes for captures, times one shot to land on a jumper, and holds a ricochet that would come back at him. The HUD names the pilot, the scoreboard shows it as the motto, and the choice survives a reload. Works through a proxy too. - Server bots: `bots` in server.json, the Operator panel's Bot Fill and Bot Pilot, or `/bot`, keep the game at that many players with bots, none once people fill it. Bots idle while nobody is connected. - Bots on `/list`: bots are counted apart from people, and a server's pane and the table heading say how many. - `-disableBots` refuses autopilot and bots. - A proxy target may set `requireLogin: false` so unregistered players can play there. ### Changed - Every shot is sent right after a position update, as BZFlag does, so a shot fired while turning is no longer refused as too far from the barrel. ## [1.2.96] - 2026-10-01 ### Added - `/list` shows each BZFlag server's owner, linked to their forum profile, and its IP address, both read from the BZFlag list server (#147). - A Launch link on each BZFlag server opens it in an installed BZFlag client. - Filters for servers with or without a map picture (`/+overview`), on generated worlds (`/+temp`), by owner (`ow)`), by IP (`ip)`) and by any world variable a server sets (`v)`). The pane counts the variables and lists them on hover. - Each list's heading shows how many rows the filter leaves. - An imported map's header records the server's owner, IP, protocol and world hash. ### Changed - A generated world keeps its picture between imports like any other, and its server is rechecked hourly, since such a world usually changes when the server restarts. ## [1.2.95] - 2026-09-30 ### Fixed - A client reconnecting after a server restart no longer sets off a linear and an angular drift warning. It sent its previous session's position before its rejoin was confirmed, and the server judged it against a tank it had not placed yet. The client now waits for the join, and the server ignores a move from a connection that has not joined. - Team bases are outlined in their team's colour on the radar and in the map pictures, as BZFlag draws them, instead of filled, so the players and the flag on a base can be seen. Pictures drawn the old way are cleared once at boot and redrawn as each world is next met (#156). - An instance reading the list server shows the list server's picture for every BZFlag world and draws none of its own, except an import it is serving; its own pictures are of its own maps. ## [1.2.94] - 2026-09-30 ### Fixed - Share View Link made while watching a server hands back a `?watch=` link to that server with `cam=` and `pos=`, not a Map Viewer link. An observer following, tracking or riding with a player, or watching a flag, is handed back as free roam from where the camera is, since that player may be gone by the time the link is opened (#160). - Fullscreen left on last time comes back on a phone's first tap. It was asked for on the touch that grants no permission, refused, and never asked again. A refused or unsupported request no longer says so in chat, and the Fullscreen row reads Unavailable where the browser has no fullscreen. The screen resolution after a toggle goes to the debug log (#158). - A map too big to write as one JSON string is cached like any other. Its cloud seed was taken over the whole world at once, which ran past V8's string limit and left the map unviewable. - `/list` shows a map's picture straight after a restart, from the saved map index, instead of waiting for the boot pass to read the map back. - A BZFlag server's picture is kept as `/overviews/bzfs-<hash>.svg`, named by its world's BZFlag hash, and outlives the two-hour import it was drawn from. The world tracker downloads a world again only when a poll reports a different hash, where it used to re-fetch every world whose import had aged out. The list JSON links only these. A picture is deleted once no listed server has used it for a day, counted only over complete passes and kept across restarts. - Every map picture, a bzo instance's, a BZFlag server's or this server's own maps', is drawn once into `cache/overviews/` and served from `/overviews/`, with its brotli copy under `cache/br/overviews/`. None is kept beside a map's JSON any more, and one already drawn is not redrawn at boot. - A remote import (`import-*.bzw`) is no longer parsed at boot or listed under Local maps, unless it is the map being served; the bzfs list shows it. It is parsed when viewed, imported or served, and a view inside the hour reads the copy on disk rather than downloading it again. The boot pass here went from 122 maps in about 90 seconds to 19 in 9. - A proxy row on an instance reading the merged list shows its target's world figures and picture, as the bzfs row for the same address does. ### Added - An H column on every `/list` list, lit when the row's world is hashed. Sorting on it puts rows sharing a world together and the unhashed ones last, and the filter box searches hashes: a bare word, or `hs)` / `hash]` (#155). ### Changed - `server.log` is terser: server.json's settings on one line naming only what differs from the default, the listen address and client build on one, an anti-cheat finding on one, and group instancing as `instanced 18/22 groups, 15 templates, 52 faces` for the served map, with every other map's summed onto the background pass's `Converted` line. A map's load counts are one line beside its cache file, and a handshake's user agent is shortened to `Chrome/152 Linux` when its shape is a known one; anything else is logged whole. ## [1.2.93] - 2026-09-30 ### Changed - A mesh travels as flat typed arrays rather than a list of face objects. A parsed face cost about 2,134 bytes against the arrays' 93, and the server held one per face for every map it had parsed, for the life of the process. `bzo.bzw` went from 847 KB to 258 KB and a cache of twenty maps from 171 MB to 32 MB. Collision, the renderer, the radar, the overview picture and the map summary all read the arrays; nothing reads a face. - A world's sizes read `bzw/json/br 101k/330k/49k` on one line instead of three, and a BZFlag server's transfer reads `sent/inflated 9k/39k`. The local map table gained the brotli figure it never showed. ### Added - A bzo server reports what its own world costs -- the `.bzw` it was read from, the parsed `.json` and the brotli sidecar -- so a bzo row says what a BZFlag row already said. A proxy row shows its target's figures. `GET /api/list-server/list` carries exact byte counts; `/list` rounds them. ### Fixed - Geometry placed by `group` instances casts its shadow under each instance. It was drawing the whole definition once at the map's centre, because an instanced batch keeps its placements in a buffer a plain mesh ignores. - A brotli sidecar is built for a map as soon as it is imported, instead of only at the next restart, and a restart no longer deletes every map's sidecar before rebuilding it. - An overview picture keeps its brotli sidecar. The map cache sweep knew only about `.json` sidecars and deleted every `.svg` one it found, so the pictures were rebuilt by each pass and destroyed by the next, and always served uncompressed. - A large world has time to arrive. The slowest listed world takes about 80 seconds to fetch and rebuild, against a 45-second limit, so it could never be imported at all -- and in the background a timeout costs a six-hour cooldown that doubles per failure, which is far worse than waiting. Now three minutes where someone is watching, ten in the background. - A physics driver named by a `define`'s own mesh faces resolves. Only obstacles were walked, so a template's faces kept the bare name and behaved as though they had no driver. - A zero-area face in a `define`'s own mesh is dropped, the way one in a placed mesh already was. ## [1.2.92] - 2026-09-30 ### Added - A map's `group` instances are drawn from one copy of their definition rather than a copy apiece, and every placement of a definition draws in a single call. `bmbz.ducatileague.org:5180` -- "Mini Field of Flowers", 1,939 placements of 7 definitions -- expands to 1,863,750 faces and about 3.7 GB, more than `JSON.stringify` can return as a string, so bzo could not load it at all. It is now 1,905 placements over 5 templates of 5,344 faces, 1.07 MB over the wire, less than the `.bzw` it came from. On a Jetson its 1.87 M triangles draw in 121 calls and 1.37 ms, against 2,460 calls and 13.67 ms placing them one by one. Across the maps here, 7,060 placements now draw from 728 templates. - Definitions nothing collides with are carried as a template alone; a solid one is still expanded, because collision on both sides reads those copies, and only where it is *drawn* from changes. A definition placed in several colours shares a template per colour. Part of #153, which still wants the denser geometry representation and instanced collision. ### Changed - The observer's Following/Tracking line writes a callsign the way chat and the scoreboard write one: the player's own colour, the cyan `+` or `@` before it, and `/FLAG` after it in the flag's colour. The words around the name stay plain, being about the choice rather than the player. Closes #154. - The centre crosshair is gone. Upstream draws the two motion boxes and no centre marker, and with no pointer lock the cursor already says where the shot goes. ### Fixed - A fullscreen request that the browser refuses no longer reaches the console as an unhandled rejection. `requestFullscreen` returns a promise, so the refusal never passed through the `try` that was meant to catch it, and Chrome reported it as "TypeError: Permissions check failed". - A `keydown` carrying no `code` -- a synthetic event, or some input methods -- put an `undefined` in the held-key set, which the debug readout then tried to read a name off. ## [1.2.91] - 2026-09-30 ### Added - Each `/list` heading says how many of its rows have no map image yet, when any do. A BZFlag row shows the picture of the world this instance holds an import of, and a proxy row shows the picture of the server it carries -- that target being a BZFlag server too, and often one already imported. ### Changed - Parsed worlds, their overview pictures and the world hashes collected from other servers live beside the config file now, so `/data/cache` in Docker rather than inside the image, where an upgrade threw them away and the next boot re-downloaded every listed server's world. `CACHE_PATH` overrides it; a source checkout is unchanged. An index of which map file produced which cached world rides along, so a restart knows what it holds before it has re-read any of it. - A world download gets 45 seconds where a person is waiting and two minutes where the world tracker is, having had 15 whatever asked -- which a league server's map cannot finish in. - An import anyone triggers records the world hash it paid for, so the tracker does not repeat that download and an operator can refresh their own server's picture rather than waiting for the daily check. - A world that fails the same way every time is retried less and less often, up to about weekly, rather than every six hours. An overview picture that no map and no reported world names any more is removed once the tracker has a current answer for every listed server and the picture is a day old. ### Fixed - `logError` rendered an Error as `{}`, so a real failure reached the log saying nothing at all. It prints the stack now, which is how the one world bzo cannot parse came to say `RangeError: Invalid string length` (#153). ## [1.2.90] - 2026-09-30 ### Added - Every local map has an overview picture, drawn into a third column of its row's pane on `/list`. It is an SVG built from the same geometry the radar panel draws, cached beside the map's JSON under the same hash, and it is a rendering at a known size rather than a vector copy of the map -- obstacles are rasterised onto a 256-cell grid and that grid re-encoded as merged rectangles, so the file is bounded by the picture and not by the world. `ahs3_Paradise_Valley`, 81332 mesh faces and 74 MB of JSON, draws in about 300 ms and lands at 10 KB brotli; the rest are between 2 and 12 KB. Elevation carries as opacity above a datum -- the altitude the map is actually played at, so a map played 200 units up does not read as uniformly high and a terrain covering the whole world does not fill the frame. Bases keep their team's radar colour and a map's painted surfaces keep their own, shaded towards the panel's neutral the way the radar shades them. A map the background trickle has not reached yet shows the app's own mark, dimmed, rather than a word. Part of #147. - Every bzo server row on `/list` carries its world's overview picture too, drawn by the designated list server rather than by each instance, so the algorithm lives in one place and every row is redrawn by the next version of it. A report names the world by content hash and never by URL -- the list server fetches `<the url already on file for that key>/maps/<hash>.json`, which is public, immutable and brotli-compressed already, and rechecks the hash against what arrives before drawing. A world it already holds needs no fetch, so its own row and any row playing one of its own maps get a picture at once. A bzfs row and a proxied target still have none: that needs the world imported first. Part of #147. - A BZFlag server row on `/list` shows its world's picture once this instance holds an import of it, which needed nothing new: an import is registered like any other map, so it already has an overview drawn beside its JSON. A row with no import shows the waiting mark, and following the row imports on demand. The server probe also asks `MsgWantWHash` now and reports the answer, which is what will tell bzo whether an import has gone stale without downloading the world again -- `p` for a world from a file, stable across that server's restarts, `t` for a generated one. Both that and the world download are answered before `MsgEnter`, so neither needs a login or an observer slot. Part of #147. - The designated list server keeps those imports fresh on its own, so a BZFlag row has a picture before anyone asks for it. It rechecks a server's world hash every 24 hours, or sooner if the public list entry is new or its listed configuration changed, and downloads the world only when the hash says the import is stale or missing -- so a server whose map never changes is never downloaded twice. One server per 30-second tick and at most one import a minute, so a list bzo has never seen fills in over hours rather than all at once; a server that refused or timed out is left alone for six hours. `bzfsWorldThumbnails: false` turns it off. Part of #147. - An instance now gets both server lists from the designated one in a single call, where it used to fetch the public BZFlag list itself and the bzo list separately. The saving is not the round trip: only the list server knows which rows have a picture, and joining that to a list each reader fetched for itself would mean matching two copies that aged apart. With no list server configured or one it cannot reach, an instance goes upstream as before. Part of #147. ### Fixed - `/list`'s local maps no longer offer `random` on a server playing a map file. It was listed as viewable and its row linked to `?viewmap=random`, but the client checks the request against the maps the server has actually registered, so the name was never there and the link fell through to an ordinary join. `random` is a map an operator can *serve* -- the instruction to generate a world, which the Operator panel's chooser still offers -- and it is a map anyone can *view* only while it is the world being played, which is when it holds a registry entry, real counts and an overview of its own. Closes #152. ## [1.2.89] - 2026-09-30 ### Added - Chat lines carry the time they arrived, `[17:02:41]` in front of the words the way BZFlag's own `controlPanelTimestamp` writes it. It is always on here rather than off by default, because bzo's transcript survives a reload: the top of the window is chat from before the tab came back, and the stamp is what tells it apart from what was just said. A line from another day -- what a tab left open overnight leaves in its scrollback -- carries the date as well, which is upstream's second timestamp format. A narrow transcript drops the clock, where nine characters in front of every line would wrap it, and keeps the dated stamps, which are the few lines it is there for; `/savemsgs -t` still writes both. Narrow is measured on the panel rather than on the screen, so opening the debug panel or the virtual controls -- which is what takes a 900px window's chat down to 550 -- drops the clock as a phone does. Closes #150. ### Fixed - One `--- reloaded ---` divider after a reload rather than one per reload. The divider marks where this reload happened, so it is no longer kept in the cached transcript that the next one restores. ## [1.2.88] - 2026-09-29 ### Changed - `/list` shows servers the way BZFlag's own server menu does, instead of two tables fifteen and fourteen columns wide. A row is one line -- player count, a game-type `*` whose colour says which of the four it is, `J F R` for jumping, superflags and ricochet, then the address and a title coloured by the shot count -- and a readout pane above the list carries everything about the row you pick: per-team counts and maxima, shots, style, the option words, the shake conditions, the score and time limits, and a bzo row's map, version, voice and URL. Ten rows show at a time, which is upstream's own page size, so its PageUp/PageDown paging is replaced by a scroll box. Clicking a row selects it, arrow keys move the selection and Enter takes it; the pane's own link is the way in. A one-character header over each column sorts by it, and each heading says how many servers are under it and how many people are playing on them, observers counted as neither. The pane is two columns like upstream's panel -- who is playing, then what the game is -- and the selected row's buttons sit on the filter bar, so the way in stays in one place instead of moving as a pane grows. Every team keeps its label with the value left blank where that team is not offered, so the pane holds its height as the selection moves. Refs #147. - A bzo server reports what the pane needs -- per-team counts and maxima, the shake timeout and win count, and the time, team score and player score limits -- so a bzo row says as much as a bzfs row, which gets all of it free from its ping packet. An instance too old to report them shows the lines it has rather than reading as a server with no teams. ### Added - `/list`'s filter takes BZFlag's own filter language, and the `?` beside the box opens the whole syntax table -- the same one upstream's in-client help menu prints. Plain text is a glob over address and description; a leading `/` starts comma-separated filters combined with *and*; a second `/` starts another set joined with *or*; a filter is `+name`/`-name`, a name with `< <= > >= =` and a number, or `name)glob` / `name]regex`. Two deliberate differences from upstream: `F` is free-for-all (its own table gives the letter to both `ffa` and `favorite`, so the documented meaning stops working, and bzo has no favourites), and `i` and `I` both mean inertia (its parser takes one and its help page prints the other). Refs #147. - An `O` column counts observers beside `P` for players, on both lists. - A bzfs row's pane names its address and, where this server holds a copy of that map, when the import was fetched -- which is what a bzo row's map, version and URL are for that one. - A bzo row's pane says how long that server has been up. The list server stamps the time when a `boot` report arrives and leaves it alone on every other reason, so this is its own observation rather than a server's claim about itself, and it survives the list server's own restart. A clean shutdown stops the clock. Closes #106. - The local maps list has the same shape as the two server lists: a row carries the obstacle, face and world-size numbers the in-client View picker shows in columns, and its pane carries the style the map sets, its boxes, pyramids, meshes, bases and teleporters, whether it has water, weather or custom ground, and the hash, mtime and file sizes. Nothing is computed to show it -- these are the stats the map registry already keeps for the client's own picker. - The bzfs list has an `I` column for a map this server already holds an import of -- the one fact in a row that is about this server rather than that one. Sortable like the rest. - A bzfs row offers **Watch** to a signed-in admin, beside View map and Import -- the same offer the in-client View list already makes, on the same test. ## [1.2.87] - 2026-09-29 ### Fixed - A shot leaves the barrel it is drawn coming out of. render.js reads the muzzle off the model's own barrel tip, and every generated tank had that tip modelled too high and, on Modern and Wheeled 6, further forward than the clamp allows -- so the flare was drawn up to a third of a unit behind the visible end of the gun and about an eighth of a unit above where BZFlag puts it. All three now reach 3.0 forward at BZFlag's own 1.57 muzzle height. - The Wheeled 6 hull fits inside the box the server slides it along a wall with. It was modelled 3.15 across and 6.2 long against a tank that is 2.8 by 6.0, so it stood proud of its own collision box and clipped into faces it slid down. - Camo is the same size on every part of every tank. A patch is four world units wherever it is drawn, the way a tread link already had a size of its own, instead of each part inheriting whatever scale its geometry happened to hand back. The Wheeled 6 hull was tiled about once per unit -- fine noise rather than camo -- and its turret was stretched along the top and striped across the ends, because the top face was given the long axis twice over while the ends were given almost no span at all. Closes #148. - A wheel notch scrolls the chat transcript and the scoreboard by the same amount on every browser and every mouse. It used to move by whatever number the browser happened to put in the event, which is pixels on some and whole lines on others -- so one notch could hide most of the six-line transcript on one machine and barely move it on another, and on the roster it skipped ten names at a time. A notch is now two lines of chat, or exactly one roster row. Closes #149. - Chat stays on the newest message when the panel changes size -- going fullscreen with `F`, leaving it, or rotating a phone. It used to leave you a couple of lines above the bottom, because a resize moves the bottom without moving your place in the transcript. Someone scrolled up to read back is still left where they were. Refs #149. - The scoreboard fits the screen at any player count. It used to grow with the roster until a busy server pushed the name row, the gear and the team scores off the bottom edge; now the panel stops above the chat folder and the roster scrolls inside it, by wheel or by dragging it with a finger. Your place in the roster survives a repaint instead of snapping back to the top every time somebody scores, and the `U` hunt cursor scrolls the list along with it rather than moving to a row below the fold. - The barrel is left out of the first-person view, as it is upstream. Nothing hides it there normally: the eye sits at the tank's own centre at muzzle height, inside both turret and barrel, and every face of a solid points away from a camera within it. A bore is the one surface that faces the axis the eye is on, so it had to be taken out by hand. A headset keeps it -- there the gun is the only thing that says where the tank is pointing, and a head that moves can look over it rather than along it. - The faces that close the hull and the turret are painted at the size the faces around them are painted, measured off the part rather than assumed. Upstream's coordinates index a skin rather than tile a pattern, so there is no one scale to use: the body runs about 2.4 world units to a unit of texture and the turret nearer 3.8. ### Added - The treads on BZFlag Medium and BZFlag Low run the tread pattern along the track by arc length, so a link is the same size as on every other tank, and their flat side plates take the tread's end texture, which nothing scrolls. Upstream has no animated tread at these levels of detail at all -- the belt is high geometry it only builds when `animatedTreads` is on, and what these carry is its static casing. - Two more tanks, BZFlag Medium and BZFlag Low, which are upstream's own cheaper tanks -- the ones it falls back to at distance. 88 and 52 triangles against the stock tank's 1106. They come from the BZFlag source rather than from its `misc/tank.obj`, which is packaged but never read by the client: what upstream draws is built in C++, one function per part per level of detail. Refs #148. - A tank stuck inside a building says so. `Sealed` appears top-centre with the other notices, in the warning colour, for exactly as long as the tank is inside the wall -- a state rather than a message, so it clears the moment the tank is out. A Phantom Zone tank inside a wall is zoned rather than sealed, as upstream has it. Closes #140. - `/mv` places a tank holding Oscillation Overthruster exactly where it is asked to, instead of resolving its height to the nearest surface. Inside the wall is where a phased tank is supposed to be able to sit, and it is how the sealed state is reached on purpose. - The debug panel counts the keys the browser is delivering, and the most it has delivered at once. Holding a combination that does not work and reading the peak says which half of the machine to blame: three keys means the keyboard sent all three and the game lost one, two means the keyboard never sent the third and no change to bzo can recover it. Most keyboards wire their keys in a matrix that cannot report some three-key combinations at all. Refs #146. - Two more tanks again, BZFlag High and BZFlag Treads. High is upstream's own full-detail tank with its casings as one piece per side, which `bzflag.obj` is not -- that one was cut into bzo's three-part tread convention long ago, so its belt is three objects each scaling its own way. Treads is the tank upstream draws with animated treads switched on: a real belt running round two drums with four road wheels a side, ported from its source because it is built rather than written out. Refs #148. - Every part of every extracted tank is a solid in its own right, so the pieces thrown when a tank explodes look like pieces of a tank from any angle. The hull had no sides at all and the turret no floor -- upstream never draws them, because its casing is a solid housing that covers the gap, and a real belt does not. Holes that open into the part's own bulk are left alone: the two tabs at the back of the hull fly with the hull, which closes them. - The gun on BZFlag Treads is a pipe rather than a capped rod, with a ring of metal closing the wall at each end, so it reads as a barrel when it tumbles past you after a kill. Medium and Low get a solid barrel instead; they are there to be cheap, and a bore is a few pixels at the range they are for. ## [1.2.86] - 2026-09-29 ### Added - Dying works on a proxied BZFlag server, which is most of what was still missing from playing on one. All six of BZFlag's deaths count -- being shot, run over, genocide, self-destruct, drowning and a death pad -- and a captured team flag kills the losing team. You respawn afterwards, and a Shield still saves you and costs you the flag instead of your life. - Guided Missile lock-on works on a proxied server. The missile curves on your own screen as well as everyone else's, and the lock drops when its target dies rather than following them into their next life. ### Changed - The motto field shows which bzo a proxied connection came through, and is not editable there. That line is what the far server is told, so it is not yours to set; your own motto is untouched and comes back on a local game. ### Fixed - Shots fired on a proxied server carried no flag, so every flag shot reached the other players as a plain shell: a Guided Missile flew straight and ricocheted off walls, a Laser was an ordinary bullet, and so on for the rest. - Shock waves fired on a proxied server are now fired from under the tank with no velocity, as BZFlag does. A server that checks shot speeds refuses any other, and drops the shot without saying so. - A tank that died on a proxied server went on telling that server it was alive until it respawned, so other players saw it still driving around. ## [1.2.85] - 2026-09-28 ### Added - Watch a live game on any server in the public BZFlag list, from the View dialog, without leaving the browser. It is an observer connection: you see the match and can chat where that server allows it, and cannot play. The server is shown your bzflag.org callsign with a note saying which bzo you came through, so its operator knows who is there. Limited for now to an admin of the bzo instance, signed in. - Players have a motto, as they do in BZFlag: a short line shown beside the callsign on the scoreboard. Mottos set on a proxied or watched server are shown too, which is what they were missing before. A player's own motto stays on this server -- what a proxied connection tells the target is which bzo it came through, and nothing else. ## [1.2.84] - 2026-09-28 ### Changed - Viewing a map has moved out of Player Options and into the View dialog, which already listed local maps and running BZFlag servers. Map Viewer is no longer a team to pick, and the map row beside it is gone. Clicking a row is what views it, on both lists -- a column of identical View buttons was noise -- and rows answer the keyboard as well as the mouse. - A remote server takes one press rather than Import, wait, then View, and the wait is shown: the same progress a `?viewmap=` link has always put up, which a press in the dialog never did. Re-import stays on servers already fetched, since a map imported an hour ago is not what that server is running now. - Local maps say what they are before anyone opens them: world size, object count, mesh faces and style, with the breakdown of boxes, pyramids, meshes, bases and teleporters on the row. Faces is the one worth reading -- among the bundled maps, 47 objects is 81,332 faces on one and 4,280 on another, while 409 objects is 56 -- so it is what tells you whether a map is worth opening on a phone or in a headset. Style is read from the world file the way bzfs reads it: a map with bases is CTF. - The View dialog is larger, up to almost the whole screen, and each table scrolls its own overflow rather than the shorter list sitting below all of the longer one. - Playing on a proxied server no longer stops at a disabled OK. The button reads "Sign in & Play" and does it, landing on the team that was picked. The tank row follows the server being chosen rather than the page, so picking this server from a proxied one brings it back with the model you saved, and a server in that list is named by its address rather than by its description, which two servers can share. ## [1.2.83] - 2026-09-28 ### Added - A browser can play on a proxied BZFlag server, not only watch it. Pick a team in the entry dialog and the connection enters the target on it, your tank spawns where the target puts it, and your movement, shots and flags all reach the real game -- native BZFlag clients see them. The teams offered are the target's own, read from the per-team maxima it reports rather than from this instance's configuration, so a team it would refuse is never on the list. Playing needs a global login -- a connection without one watches, and is told why -- so the target's operator can act on one proxied player with `/idban` rather than on all of them with `/ban`. Dying is not carried yet: a proxied player cannot be killed. - The entry dialog picks which server to play on: this one, or any BZFlag server this instance proxies. Until now a proxied server could only be reached by a link, and from inside a running game not at all. The row is hidden when there is nothing to choose between, the team beside it offers what the chosen server would actually accept, and picking one is a page navigation, so the address bar always says where you are. Playing on a proxied server greys OK until you have signed in, with Global login beside it carrying both the server and the team you picked. - Pausing and jumping reach a proxied target. The pause is declared to it, so a paused browser is paused for everyone rather than only on its own screen, and the airborne state rides on the player update -- which a target needs, because it skips the vertical part of its shot-origin check for a falling tank and drops a shot fired mid-jump without it. - The Identify flag works on a proxied server. The target volunteers the nearest flag off its own search, and the name it sends is matched to bzo's flag table and written into the flag record, so the label, the radar cross and the scoreboard all read it exactly as they do in a local game. ### Changed - bzo names its own release everywhere it introduces itself to somebody else's host. Proxying a bzfs server and importing a map from one both carry it in the version string `MsgEnter` takes, in the shape `getAppVersion()` builds, so a target operator's `/clientquery` and their log say which bzo called on them. Every outbound HTTP request carries it too, as `bzo/<release> (<build>; +https://github.com/timriker/bzo)`. No request bzo makes states a version it is not. ### Fixed - A tank parked on a proxied server no longer loses the flag it is holding. A bzfs learns a player is still there from their position updates and nothing else, and drops the flag of anyone silent for five seconds; bzo's own heartbeat was also five seconds, so a resting tank raced that limit and lost about half the time. How often a client reports is now the server's to say, and a proxied connection uses the one-second interval a native BZFlag client uses. - A tank belonging to a player who has joined but not spawned is no longer drawn at the centre of the map. The cloaking pass owns how transparent a tank is, but it was also deciding whether the tank was drawn at all, and the first frame after a tank is built always took that branch -- putting back on screen the tank that had just been hidden for not being alive. A player who died later never showed this, which is what made it look intermittent. ## [1.2.82] - 2026-09-28 ### Fixed - A tank phasing inside a mesh is drawn again, rather than vanishing and leaving its flag hanging in the air. Two separate faults. `OO`'s crossing effect clips a tank to the plane of the wall it is half inside, and the mesh version of "which wall" returned the first face the tank touched instead of the first one it actually straddled -- a tank standing inside a solid always touches its floor first, whose outward normal points straight down, so the cut took the whole tank. And the cut was applied to the tank the view was looking out of, where upstream never applies it (`Player.cxx:961-963`): the clip is only there to give the interdimensional lights a clean coplanar edge, and the depth buffer does the real occluding either way. Your own tank in first person, and a tank being roamed in its "Driving with" view, now get the lights without the cut, matching upstream's `inCockpit`. Third person, TRACK and FOLLOW keep it, so a tank half through a wall still shows its back sticking out. - A tank standing behind a `nosorting` face is hidden by it, the same as on BZFlag. Upstream's flag does not merely skip a sort -- it takes the face out of the sorted pass entirely, so the face blends, writes depth, and shares a pass with the tanks, which it can then depth-reject. bzo set `depthWrite` and left the material flagged transparent, but Three.js buckets on that flag rather than on the depth mask and always draws its transparent list after the whole opaque one, so the tank had already been drawn and the depth write had nothing left to reject. A `nosorting` material now leaves the transparent list and blends in the opaque pass instead. ## [1.2.81] - 2026-09-28 ### Changed - The caution tape on `hix`'s walkways, bridges and platforms now crawls, circulating clockwise about each object it wraps at 0.4 units per second -- a tile every 20 seconds, against a tank's own 25 units per second. A shift alone was not enough to get one direction out of it: two of the walls ran their `u` axis against their own mesh's winding, so a single shift sent the tape opposite ways on neighbouring walls. Every wall wearing the tape now runs `u` the way its mesh winds. - `hix`'s ground wears zaphod's asphalt, the texture `import-Planet-MoFo.com_4202.bzw` names in its own `GroundMaterial`, and the concrete every top wears tiles at four times the size. ### Fixed - `hix`'s walkway corners no longer flicker. Where the edge ring met a diagonal bridge, three decks all claimed the same 15x15 of ground at the same height, and coplanar faces facing the same way have no depth order to settle on. The ring is now cut back from the point where its own inner wall meets the bridge's side edge out to the map corner, and the bridge tapers to a point at that corner between the two cuts, so all three decks share the corner vertex and none of them overlap. Each cut face is the same quad as the bridge face it lands on, wound the other way, leaving backface culling exactly one of the pair to draw -- which matters here, because the walkways have see-through undersides and those edges show from below. - One of `hix`'s bridge walls tiled 3.6% longer than the wall opposite it, from the far end of a mirrored texture coordinate being signed wrong. Invisible while the texture sat still, not once it moves. ## [1.2.80] - 2026-09-27 ### Fixed - Driving from one mesh's flat top onto another's no longer stops dead at the seam. `getTankHitNormal` searched for the struck face a second time and took whichever came first in the mesh's own face list, throwing away the ranking the collision search had already done; a perimeter wall the tank only grazes because gravity sinks it a few thousandths of a unit below the shared top then answered in place of the top it was driving onto, facing back the way it came. Upstream never chooses twice -- `World::hitBuilding` sorts its hit list with `compareHitNormal`, up planes first, and hands that `MeshFace` back as the obstacle -- so bzo now ranks the same way. Two boxes never showed this and neither did a box meeting a mesh: a box answers from `findTankObstacle`'s own early return, before any face is weighed. `bzo.bzw`'s hexagon honeycomb is the fixture, and `npm run test:mesh-seams` is the guard. - A flat mesh face is chosen by the height the step began at, which is upstream's own test (`World.cxx:335-341`), rather than by whether the tank's centre point sits inside the face's polygon. The centre test lost a tank the floor it was standing on whenever that centre passed beyond the floor's edge while its body still rested on it -- crossing the two-metre gap built into every `Catwalk` joint on `import-xs.bzexcess.com_5155.bzw`, the deck stopped answering and the segments' end caps were all that was left, so holding forward moved the tank slowly backwards and reverse moved it forwards. - A mesh face a tank travels exactly along no longer blocks it. The dot product that decides "driven into" against "slid along" is mathematically zero there, so its sign came down to how the face's own plane coefficients rounded, and a blocked step with no velocity component to cancel resolves to no progress at all, every frame. It is now measured against the step's own length. - Reporting a collision with a mesh no longer throws. The anti-cheat log line read `x`, `w`, `d`, `h` and `rotation` off the obstacle, which a mesh does not have -- it is vertices and faces, with `bounds` for a box -- and the `TypeError` took the whole message handler with it, dropping the movement update the line only meant to describe. - `mesh_cube` in `bzo.bzw` had its bottom face wound counter-clockwise, so its normal pointed up into the cube and the engine read the cube as having an up-facing floor at ground level. The `Hexagon` prism beside it was already wound the other way. - `--mv` and `--chat` in `scripts/headless-client.mjs` reach the server again. Focus, text and Enter were three separate CDP calls, and a headless window has no real OS focus to hold between them, so the text landed nowhere and the command was silently dropped. They are one page-side call now. ## [1.2.79] - 2026-09-27 ### Added - The HUD's title row reads `[Settings] [Mic] @name/flag [chevron]`. The microphone is new there: its border is green while you are transmitting and red while you are not, clicking it toggles transmission as `B` does, and the hover text says which it would do and on which channel -- `Disable Mic - Nearby`. - The title carries the login character a scoreboard row already does, so a signed-in player reads `@Tim Riker` or `+Tim Riker`. `@` for an admin and `+` for a verified callsign are upstream's own indicators (`ScoreboardRenderer.cxx:718`). ### Changed - The voice panel that sat beside the targeting box is gone; the microphone button in the title row says the same thing in the corner where the other HUD controls already are. - `bzo.bzw`'s north-east corner is a honeycomb of three identical hexagons -- one built from three overlapping planks, one a real mesh, one a single `arc` with `divisions 6` -- each sharing a whole face with the other two, so driving from any one onto either other crosses a seam with nothing between the surfaces. It replaces the octagon pair, which tested the same three ways of building a prism without ever putting two of them edge to edge. - `bzo.bzw`'s test fixtures no longer sit inside each other: the hollow arc and the nested pyramids moved to the west wall, the sphere north of the hemisphere, and the tetrahedron and the overlap stack out of the plain arc's footprint. ## [1.2.78] - 2026-09-27 ### Added - A chevron beside the gear hides the scoreboard and the chat window together, leaving the name row in the corner and the chat tabs along the bottom edge. BZFlag gives the two a key each -- `S` for the score sheet and `W` for the console -- and both letters drive a tank here, so the pair sits behind one control that a finger can reach. What needs a panel borrows it and gives it back: typing a message opens the chat window until the caret leaves it, and `U` or `7` opens the roster until the hunt cursor closes -- upstream force-opens its score sheet for the cursor too, and leaves it open, having no collapsed state to return to. Tapping a chat tab opens chat for good, since reading a tab is not an action that ends. The chevron's own state is remembered; a screen narrower than 600px starts collapsed, where the two panels otherwise cover most of the battlefield. Closes #145. ## [1.2.77] - 2026-09-27 ### Added - The chat window keeps its transcript across a page reload, and a `--- reloaded ---` line marks where the reload was. bzo reloads the tab on its own whenever the server ships new client code, which is many times an hour while something is being worked on; BZFlag needs none of this because its client is a process that sits through a server restart with its window intact. The last 200 lines are kept, in the tab that wrote them rather than across every tab on the same origin, and only until the tab is closed. The Debug tab is not kept: those lines already reach `server.log`. Refs #144. - The twenty lines under `Up` in the chat entry now outlive the tab too, beside the callsign and the highlight pattern already stored there. Refs #144. - `/savemsgs [-t] [-s]` saves the All tab, as BZFlag's own does -- with the same header and the same `msglog-<when>.txt` name, arriving as a download because a browser has no config directory to write it into. `-t` stamps each line with its date and time. `-s` strips colour codes upstream and does nothing here, bzo having none in the text to strip. Closes #144. ## [1.2.76] - 2026-09-27 ### Added - The chat entry recalls what you have typed before, with `Up` and `Down`. BZFlag keeps the last twenty lines, newest first, and a line said twice moves to the front rather than being stored twice. Typing the start of a line first narrows the recall to the lines beginning that way, so `/m` then `Up` reaches the last `/msg` rather than the last thing said, and `Down` past the newest match puts those characters back. Refs #143. - `Tab` completes a word in the chat entry: a command, a player's callsign, a player's `#3` slot, or a flag's abbreviation, picked by the shape of the line rather than by a table of commands and their arguments. `@ti` reaches `@Tim` and stays a mention in whatever channel it was aimed at; `/msg ti` reaches the same player as a command's argument, quoted if their callsign has a space in it. Matching ignores case, several matches fill in as far as they agree and list the candidates in the Misc tab, and a bare `#` asks who is in which slot. The command names come from the server rather than a list kept in the client, so they cannot drift, and an operator's own commands appear once they log in. Closes #143. ## [1.2.75] - 2026-09-27 ### Changed - A shot on the radar is a line along its own direction rather than a dot, so the panel says which way it is going. BZFlag draws the line forward from the shot -- where it will be, not where it has been -- at `_shotTailLength` times `linedradarshots`, twenty world units on the defaults, and tops it with a grey tip at the shot's own position. It is a world length, so it grows and shrinks with the radar's range the way the map under it does, and it comes off the shot's live direction: it turns with every ricochet, every teleport and every step a missile steers. Lasers and shock waves already drew as a line and a circle and are unchanged. Closes #142. - A guided missile throws flares, which is what BZFlag gives it and gives nothing else (`setFlares(true)`). Three to five spikes in directions picked at random out of the bolt, re-picked every frame and reaching twice its radius, so the missile flickers and breaks the round silhouette every other shot has. bzo already had the missile's animated sheet and its smoke trail, and those alone left it reading as an ordinary bolt in the shooter's colour. Refs #6. - A guided missile's line on the radar is a third longer than any other shot's. BZFlag draws them all the same length, but a missile is the one shot whose direction is a live answer rather than a fixed one, and the one you most need to read off the panel in the moment you have to turn away from it. Refs #6. - Shots on the radar take the colour the radar draws their owner's tank in rather than the lightened colour the bolt wears in the world. A bolt is lightened to read as hot against the sky, which a two-pixel line on a dark panel does not need -- it only made every shot read as white. BZFlag draws its radar shots from the radar palette too. ## [1.2.74] - 2026-09-27 ### Changed - A tank's camo is retargeted in HSV rather than tinted, so it carries more than one colour. BZFlag paints a finished texture per team and draws it unmodulated, which is why its four patches differ in hue and saturation and not only in brightness -- bzo has a colour per player rather than per team, so the pattern is generated instead: a source camo's hue is rotated until its own average lands on the player's, and each texel keeps the offset it was painted at. Patches above the source hue stay above the player's and ones below stay below, so they read as different shades of one colour. The source's own saturation decides how far a texel follows the player at all, leaving the areas an artist left grey grey on every tank, and the lit areas give some of their colour back the way a real highlight does. A player's own shade scales the finished pattern, so two players on a team still differ in the world and not only on the scoreboard. Closes #139. - Rogue tanks are a plain dark grey in the world, as upstream's are. BZFlag paints rogue_tank.png in greys with no hue anywhere in it and gives the team its yellow only on the radar and the scoreboard, which is where a rogue is picked out of a list rather than off the field. bzo keeps the yellow in both of those places and takes it off the tank entirely -- not a trace of it, since every rogue shade bzo assigns is a yellow one and any fraction of it reads as a yellow tank. A rogue lands within a few counts of upstream's own (55,55,55). Only where there are colour teams for Rogue to be one of: on a world without them every player is nominally a rogue while wearing a colour of their own, and blacking them all out would throw that away. Part of #139. ### Added - `scripts/crowd-client.mjs` fills a server with parked tanks over the wire, no browser each, for looking at a change across every team at once. - `scripts/headless-client.mjs` takes `--team`, to join as one rather than taking whatever Automatic hands out. ## [1.2.73] - 2026-09-27 ### Changed - A shot slot is the weapon reloading, not the shell flying. A slot is taken when it fires and comes back when its own reload runs out, so a shot that stops against a wall a metre away costs exactly what one that flies its whole range costs -- where before, hitting something nearby handed the slot straight back and a close-range wall was a free reload. Both ends of BZFlag work this way: its client reaps a slot when the reload is up and never when the shot ends, and its server leaves a stopped shot's expiry where it was. Closes #141. - Firing is no longer spaced by a fixed interval. BZFlag waits on one thing, a free slot, and a player empties their slots as fast as they can click; bzo held every shot to `_reloadTime / maxShots` apart whether or not slots were free, which on a five-slot map like `hix.bzw` was 700ms between shots that could all have been fired at once. The slots are the only limit now, which is also the only limit the server has ever enforced. - Holding the trigger and tapping it are told apart. A press may fire again 100ms later -- about as fast as a mouse can be clicked, so a deliberate burst is as quick as BZFlag's -- while a trigger that is simply still held repeats at the world's sustained rate instead. BZFlag reads its trigger once per press on a mouse and has no answer for a touch button, an XR trigger or a gamepad, where a held control would otherwise fire on every frame and a thumb resting on the fire button would empty every slot at once. Holding stays the convenient option and is never the faster one. - Trigger Happy spaces its own shots, as upstream does: it pulls its trigger every frame, and the reload it forces after each one is what stops a tank nobody is aiming from firing its whole magazine into the ground the instant it picks the flag up. ### Fixed - The shot bars beside the control box fill on the slot's reload rather than the shell's flight, so a shot that stops early leaves its bar filling instead of snapping to ready. The bar in a headset had the same fault and got the same fix. - A map that states its own `_reloadTime` now sets how long shots live, on both the server and the client. Both had been working it out from `_shotRange / _shotSpeed` and ignoring the map's number. - Another bzo instance listed on `/list` shows the map it is running, and the BZFlag servers it proxies. The list server accepts a report by copying only the fields it knows, and neither of those was among them, so every row but the list server's own -- which reports to itself without passing through that copy -- arrived with both stripped. ## [1.2.72] - 2026-09-27 ### Added - A browser watching a proxied BZFlag server traces the shots it draws. A BZFlag server leaves a shot's path to each of its clients and never says where one stopped, so a Laser is now drawn along a path worked out where it is shown -- buildings, ground, teleporters and bounces -- and a shell stops at the building, teleporter frame or ground it hits instead of carrying on through. Refs #82. ### Fixed - A proxied shell ricochets off the world border instead of vanishing at it. The proxy had been ending a shot where it worked out the shot would leave the map, which on a ricochet server is where it bounces. Refs #82. - Ricochet on a proxied server is read off the live connection, so turning it on there takes effect without the world changing. Refs #82. - A Laser on a proxied server fades at a Laser's life rather than lingering for seconds: the shot a BZFlag server forwards carries a reload time, not the flag's own life. Refs #82. - The shot that kills somebody on a proxied server stops with them instead of flying on. Refs #82. ## [1.2.71] - 2026-09-27 ### Added - Which BZFlag servers an instance proxies is configuration now, not source: a `proxies` map in `server.json` from the name players see to the address bzo dials, and nothing else can be named. Each target gets its own row on `/list` carrying that server's own game -- player count, shot limit, style, every option bit -- read off it directly rather than off the bzo carrying it, and a link of its own. A target that did not answer the last dial leaves the table, as does an instance the list has stopped hearing from: every row there is a game somebody can join. Refs #82. - The bzo list shows which map a server is on. On a `bzfs` server that fact has to live in the title, where changing the map means remembering to change the title too; bzo reports it, so the row follows on its own. A proxied row shows the server it carries instead. - `?view=` on a spectator link picks which roam view to watch in, so `?follow=leader&view=track` comes back tracking the leader rather than following them, and it survives a login. - Signing in from a proxied server returns to that server rather than to this one's own game, and the global login is forwarded to the target unspent, so bzfs is what verifies it. - `docs/installation.md`: Docker, source, configuration, reverse proxy, listing your server, proxying BZFlag servers and updating, in one place. The README points at it. - An operator on a whitelisted address manages list-server keys without a bzflag.org login. Registering one still needs the login, since a key is attributed to a BZID. Closes #136. ### Changed - A proxy link spells its target `host_port`. A `:` is legal in a URL but comes back from an address bar as `%3A`, and a link is made to be shared. - An observer may watch a cloaked or stealthed tank, and keeps watching one that dies rather than dropping to free roam. Upstream asks nothing of a roam subject but that it not be an observer; the rules bzo was applying belong to `ID` Identify, which is one tank locking on to another. - `/flag show` answers with upstream's text and nothing else, on bzo as on bzfs, and the client reads those lines to fill the flags in. One reader, so it works on a proxied server's reply too. - Chat sent to a proxied server is converted to ASCII rather than costing the player their connection: bzfs reads a line a byte at a time and kicks for anything above 126, so one accent was a disconnect mid-sentence. ### Fixed - A capture names the team whose flag it was. `MsgCaptureFlag` carries the territory the flag was carried into, not the team that lost it, so a purple flag taken into red territory was reported as a red flag captured. - A proxied player keeps their callsign across a reconnect and a restart. The session it rests on carries no BZID, and the loader was discarding any record whose BZID was not a string. - `npm run check` no longer truncates the running server's log. `server.js` clears the log on start, and `log()` rebuilt that path rather than using the one constant, so booting a second copy in the same directory wiped the first one's. `npm run check:boot` also starts the server and waits for it to come up, which `node --check` cannot do: it parses without running, so a module-level ordering mistake reached the live server before anything caught it. ## [1.2.70] - 2026-09-26 ### Changed - Player ids are BZFlag's own slot numbers now: 0 through 243, with the destinations that are not a player at the top of the byte -- 251 a team, 252 the admin channel, 253 the server, 254 everybody. bzo used to start players at 1 and spend small negatives on the rest, which made its ids disagree with the ones a BZFlag scoreboard and an id-taking command show, and which collided outright when a proxied server's player 0 spoke: their chat arrived as a message from everybody. Refs #82. ### Added - A browser can watch a real BZFlag server through bzo. `?proxy=<host_port>` points the ordinary client at an allowlisted `bzfs` this instance can reach on a private address: bzo joins that server as an observer on the browser's behalf, imports its world the way Map Viewer already does, and turns what the server tells a joining player -- the roster, the team scores, the match clock, the server's own greeting -- into one bzo `init`. The connection is held, and what happens over there happens here: tanks move, spawn, die and pause, flags are carried and dropped, shots fly, scores change and chat goes both ways, including `/` commands, which the target answers. The proxy keeps a UDP link alongside its TCP one, as any good BZFlag client does. Refs #82. - `/login/<host_port>` hands a bzflag.org login token to a real `bzfs` rather than spending it here, which is how a proxied player would carry a global identity. The answer, which the whole idea rested on, is that an unmodified server accepts it: "Global login approved!", as long as it sees the connection arrive from a private address. Refs #82. - Signing in from a proxied server comes back to that server. `/login/<key>` and `/logout/<key>` take a proxy target as their return page, so a player who signs in while watching a real match is returned to the match rather than to this server's own game. The forwarded-token probe keeps the same route under a `probe-` prefix. Refs #82. - `?view=` on a spectator link picks which roam view to watch in, so `?follow=leader&view=track` comes back tracking the leader rather than following them. `?follow=` says who to watch and this says from where -- the two axes one value could never carry together. ## [1.2.69] - 2026-09-26 ### Added - Two map warnings bzo never raised, each matching what real bzfs already says about the same map. A mesh face with no area -- three vertices that never leave a line -- is dropped rather than kept, at upstream's own threshold (`MeshFace::finalize`), and the obstacles the dropped faces came from are named in one server-log line. Separately, a zero-size box or pyramid that carries a material or a physics driver is reported, because bzfs builds that one as a mesh whose four side faces all collapse to a line, while bzo keeps it a box and so has no faces of its own to find the fault in. ### Fixed - The test death pad on `bzo.bzw` is no longer flush with the ground. A box naming a material or a physics driver is a mesh to bzfs rather than a `BoxBuilding`, and a flush one gives that mesh four zero-area sides, so every world load on a real server drew four `invalid mesh face` warnings and left the pad as two coincident horizontal faces with nothing to resolve a driver against. It now carries the same barely-real height as the conveyor and launch pads beside it. ## [1.2.68] - 2026-09-26 ### Fixed - A tank no longer spawns under the world on a map whose ground is mesh terrain. `ahs3_Paradise_Valley.bzw` puts its floor a median 95 units up, and a mesh is a shell rather than a solid, so a spawn at ground level collided with nothing and was accepted. Three things were wrong: the random spawn never dropped onto anything, the drop could not read a mesh's per-face heights and answered the top of the whole object instead -- a mountain peak rather than the floor -- and it then rejected every real landing, because bzo reads a tank resting exactly on a surface as inside it. Clearance is now probed at the height the tank will actually stand at. ## [1.2.67] - 2026-09-26 ### Added - Hunting, on BZFlag's own keys. `U` opens a cursor on the scoreboard and `7` opens it in add mode; `Up` and `Down` move it, `Enter` marks the row it is on, and `U` again clears every mark. The cursor is a pulsing `○` that fills in to `◎` when you commit. A hunted player wears a cyan `◎` on their scoreboard row and a cyan ring on the radar, with a beacon in the sky over them in their own tank colour, and while one is in your sights the HUD says `SPOTTED:` and `hunt.wav` pings once a second from where they are. In Rabbit Chase the rabbit is marked automatically, which is where the ring and the beacon over it now come from. - A **Hunt row in Settings**, under Camera, so a phone and a headset can hunt without a keyboard. Left and right step through `Clear` and then the players in alphabetical order, and select marks or unmarks whoever is shown -- or clears every mark on `Clear`. With a mouse or a finger, a chevron steps and the rest of the row selects. It is a new row kind, `pick`: the first whose sideways keys and whose select do different things, which is why it wears its chevrons around the value rather than at the row's edges. ### Changed - The rabbit's radar ring and sky beacon are the hunt's marks now, not a rule of their own, over whoever is hunted rather than over the rabbit alone. - A sky beacon over a hunted tank takes that tank's own colour rather than hunt cyan, so several marked at once are told apart -- the radar ring stays cyan, because there it is laid over a blip already in the player's colour and would otherwise disappear into it. The rabbit keeps cyan on both: it is the one target the world picked rather than one this player did. ## [1.2.66] - 2026-09-26 ### Added - A map can name every stock texture BZFlag ships. The list a `material`'s `texture`/`matref`/`addtexture` could reach held 67 of the 82 pictures in upstream's own `data/`, so a map naming one of the other fifteen -- the five mountains, `treads`, `flag`, `missile`, `puffs`, `jumpjets`, `shot_tail`, `blend_flash`, `dusty_flare` and the two explosions -- drew the obstacle's plain default here and the right picture upstream, whose texture manager resolves any name in `data/`. All 82 now resolve. - A jump pad sounds. A surface whose physics driver pushes a tank upward plays BZFlag's own `bounce.wav`, ahead of the landing and burrow sounds in the same chain it uses. `bzo.bzw` has a pad to hear it on, south of the purple base against the west wall. - Every sound effect BZFlag ships is now in the box. `hunt.wav` and the four spree samples have nothing that plays them -- hunting needs a scoreboard bzo does not have yet, and BZFlag itself references the spree samples nowhere -- so they are shipped but never loaded. ### Changed - A tank's own bounce and burrow sounds play in the ear rather than positioned in the world, which is where BZFlag puts them: they were passing through a distance rolloff that made them quieter than they have ever been there. ### Fixed - A map change no longer leaves the previous map's materials and textures behind. Only one of the seven a world teardown tried to free was a name anything was ever stored under, so every box and pyramid material survived a Map Viewer preview or a map change for the rest of the session -- and an animated one came back frozen at whatever colour or offset its animation had reached, because the registration that drives it was dropped while the material itself was kept. ## [1.2.65] - 2026-09-25 ### Changed - A shot costs two draws for its colour rather than seven for itself. A bolt was a head sprite and a six-segment trail, each segment its own sprite with its own material over its own clone of a texture the shot had just tinted by reading a whole canvas back and walking it pixel by pixel -- for an image identical to the last shot the same tank fired. A shot's look depends only on who fired it, so a colour now builds its two textures once and every bolt of that colour rides in two `InstancedMesh`es: one for the bolts, one for every segment of every trail. Which wisp a segment wears and how much of it shows are instance attributes, so the trail is still different shot to shot. Measured on `bzo.bzw` over the same twelve shots: firing one falls from 1.83ms to 0.62ms, and the twelve in the air from 98 draws to 14 -- a busy server now draws three times as many colours as are shooting rather than seven times as many shots as are in the air. A guided missile keeps a sprite and a sheet of its own, because it steps a cell a frame and two missiles sharing one would animate in lockstep. - A shot's explosion no longer stands half inside what the shot hit. A shot ends on the face it struck, and a camera-facing quad centred on that point is half buried in it. The quad now slides along the line from the explosion to the eye by its own half width, which is exactly far enough that no part of it is still behind the surface -- `BillboardSceneNode::BillboardRenderNode::render` (`:357-375`), "want to move the billboard directly towards the eye a little bit". Along the line to the eye rather than the camera's forward axis, which is upstream's own distinction: the two agree only for something dead centre in the view, and an explosion at the edge of the screen would otherwise stay in the wall. - A shot impact takes the explosion sheet already in memory instead of building one. Every burst was painting its own 512x512 canvas and uploading a texture off it, for a picture with no per-impact variation in it: the frame it is on and the angle it sits at belong to the sprite, not to the image. Each of the two source images is now drawn once for the session and an impact clones it, which copies the source by reference -- one upload, and each burst still walks its own way through the sheet. ## [1.2.64] - 2026-09-25 ### Changed - A `mesh` block draws one call per material it uses rather than one per face. Its faces were each given a `geometry` group of their own, which is a draw call each whatever they are made of. They are collated by material now, the way upstream's `MeshFragSceneNode` collates them and the way bzo already collated boxes and pyramids. Measured on `import-xs.bzexcess.com_5155.bzw`, the Jetson client's draw calls fall from 15217 to 978 and its `draw` phase from 45.9ms to 12.9ms, for the same 92712 triangles; with the radar below, its shortest frame goes from 98.6ms to 20.5ms and its frame rate from 17 to 46. - The radar draws a map at the detail the panel can show. A mesh too small on it for its own faces to separate draws its footprint instead, and a face narrower than a pixel inside a mesh too big to collapse is left out -- bzo's own answer to what a map shipping `radarLods` would answer for itself (#90). Measured on `import-xs.bzexcess.com_5155.bzw` at `?radarZoom=2`, where 459 meshes offer 16251 faces, the `radar` phase falls from 277.9ms to 39.6ms on one client measured both ways; the Jetson reads 58.0ms at that range and a desktop 26.6ms. Fine detail at a wide range reads slightly blockier for it; the shape of the map does not change. - `?radarZoom=` sets the radar's range at startup, clamped to the same 0.005 to 2 the wheel is, and every `renderer.stats` line carries the range it was taken at. The panel's cost is what its range puts on it, so a sample that did not say which range it came from could not be compared with the one beside it. URL only, never persisted, never in the UI -- see `public/test.html`. - The radar drops what is off the panel before it projects anything, and fills in batches. It used to walk every obstacle and every upward mesh face in the map every frame, take a sine and a cosine of the player's heading for each vertex of each of them, and issue a `fill()` an obstacle. Measured on `import-xs.bzexcess.com_5155.bzw`, the `radar` phase falls from 43.4ms to 4.9ms on the Jetson client and from 17.0ms to 2.9ms on a desktop. An obstacle is rejected on a circle around its own footprint, so a long walkway whose centre is off the panel still draws the span of it that is on. Where two obstacles inside one batch overlap, the union fills once rather than twice, so a partly transparent overlap no longer darkens at the seam. ## [1.2.63] - 2026-09-25 ### Fixed - A tank's callsign is hidden by what stands in front of it and by nothing else. The fade that every tank's materials go through was turning the callsign's material opaque along with the hull, which dropped the name out of the transparent pass entirely -- drawn before every alpha-textured face in the world and painted over by all of them, however far behind the name they stood. Names now write depth and sort with the scene, so a projected shadow, a track mark or a merged obstacle behind a name is rejected where the name is nearer, while a panel in front of it covers it again. - `arc`, `cone`/`meshpyr`, `sphere` and `tetra` draw with the whole of the material they name. The four shapes BZFlag expands into meshes were assembling their faces from a partial copy, so `dyncol`, `texmat`, `specular`, `emission`, `shininess` and `alphathresh` were dropped on the way and a material that animated on a `mesh` sat still on an `arc` beside it. This reaches imported maps as well as bzo's own. - Faces animating from the same named `dynamicColor` or `textureMatrix` share one material again. The block's name is what identifies it now: the server resolves one per block and hands it to every face that names it, and serializing the world gave each face its own copy -- which split a sixteen-division `arc` into sixteen materials and sixteen animation updates a frame. - Share View Link names the map on screen -- the previewed world in Map Viewer, the live match's map otherwise -- rather than the entry dialog's map picker, which is seeded with the first map on the list for every player whether or not they are heading for Map Viewer. `cam=` reads a playing tank's own camera rather than always reporting a roaming view it is not in. ### Changed - The teleporter proximity wash is the last thing the world draws, which is where BZFlag runs it: `renderDimming` closes `SceneRenderer::render` and the HUD is drawn over the finished frame afterwards. - `bzo.bzw`: the beacon and stripe test quads stand as tall as the material test panels beside them, so the row reads as one set. The tower platform's caution band scrolls, and a second inward-facing band inside the disc means it reads from under the deck -- through the see-through underside -- as well as from outside, which a single-sided face cannot do on its own. ## [1.2.62] - 2026-09-25 ### Fixed - A name over a tank is no longer painted over by things standing behind it. A label writes no depth of its own, so anything drawn after it in the transparent pass covered it wherever the two overlapped on screen -- an alpha-textured wall, a projected shadow, a flag, a track mark -- however far behind the name that thing actually stood. Every name written into the world now draws after all of them: a tank's callsign, a flag's abbreviation, and the debug labels over obstacles. A wall genuinely in front of a tank still hides its name. ## [1.2.61] - 2026-09-25 ### Fixed - Imported maps keep the name of every `group` that places obstacles, so teleporters inside those groups keep their names too. BZFlag builds a group-placed teleporter's link name from its instance name, and the link names travel over the wire as text; dropping the instance name left the imported map naming teleporters that no longer existed, and every group-placed teleporter on such a map was unreachable. A survey of live servers found 24 broken links of this shape, on maps including "Obstacle Course" and "HiX". A `group`'s material remap table, which BZFlag hides in the tail of that same name field, now comes across as `matswap` lines. ## [1.2.60] - 2026-09-25 ### Added - A map's own physics are read: `_tankSpeed`, `_tankAngVel`, `_gravity`, `_jumpVelocity`, `_shotSpeed`, `_shotRange`, `_shotRadius`, `_reloadTime` and `_rejoinTime`. BZFlag locks every one of them, which means the server states the value and each client obeys -- already how bzo works -- so a map that names one is naming how it is meant to be driven and shot on, and the map's number replaces the config's. `_reloadTime` and `-ms` are applied together, since each shot slot comes back after one divided by the other, and Wings' own gravity and jump follow the world's unless the config pinned them. Seven of twenty-two servers surveyed run a non-default `_tankSpeed`, and every one of them used to be played here at 25. - A Map Viewer preview drives by the previewed map's physics rather than the live match's. The per-map world file carries the same set, and the client lays it over its own config for as long as that map is on screen; a variable the map says nothing about keeps the live value. Jumping and ricochet stay out of it -- bzo forces both on -- and so do the flag variables, since a preview has no flags in it. - A remote import records what the source server actually chose. It enters as a momentary observer to collect the BZDB dump a bzfs sends only to an accepted player, leaves again, and writes every value that differs from BZFlag's own defaults into the exported map as a `-set` line -- including names bzo does not read itself, because a map played at `_tankSpeed 40` should say so in the file rather than quietly play at 25. A server that refuses the join costs the import nothing. - `scripts/gen-bzdb-defaults.mjs` and the `server/bzdb-defaults.cjs` it writes: BZFlag's own 167 world-variable defaults, which is what tells a server's deliberate setting from the value it would have had anyway. - `-ms 0` means what it means upstream: tanks cannot shoot. It used to be clamped to one shot without a word, so a map asking for no shooting -- a parkour course, where a shot is the one thing that can undo somebody else's run -- loaded looking as though it had asked for one. Only a negative count is clamped now, which is upstream's own split. The shot bar and its XR panel go away rather than standing empty, the server refuses such a shot outright instead of reporting a slot overrun warning mode would let through, and the load says so. `server.json`'s `shotMaxActive` takes zero too, and `maps/noShots.bzw` is a small map that demonstrates it. ### Changed - `npm run dev` watches only the map actually being played, not all of `maps/`. A restart can pick up the live map and nothing else, so importing or editing any other map no longer takes the server down -- which used to kill the very request that was writing the imported file. ## [1.2.59] - 2026-09-25 ### Added - A material's `nosorting`, `notexalpha` and `notexcolor` are read: a translucent face that asks to keep writing depth does, a texture whose alpha channel the map says to ignore draws opaque rather than blended, and a textured face the map does not want modulated is drawn as the picture is. `notexalpha` keeps a stated `alphathresh` -- that is an alpha test, and BZFlag applies it either way. - `tint` on a `group` instance multiplies its meshes' colours, so one `define` can be placed in several colours without a material per copy. Nested instances multiply, the same product upstream composes. - `shift`, `scale`, `shear` and `spin` on a `mesh`, `tetra`, `cone`/`meshpyr`, `arc`/`meshbox` or `sphere`. BZFlag reads these as one ordered transform list folded into a matrix, not as four independent settings, and that is what bzo does now -- so a mesh can be sheared, or spun about any axis, or moved and turned repeatedly, and it lands where the map says. `spin` on those blocks used to be dropped without a word: `tricolor.bzw` alone has 273 of them, and its arches, doors and towers were all left unrotated. - Overview is a camera an observer and Map Viewer can reach, after First Person and Third Person in the same `C` cycle -- a playing tank already had it as its third mode and an observer had no way to ask for one. Share View Links carry it as `cam=overview`. The virtual tank is drawn in this view only, because a camera above the map is the one place it reads as a marker of where you are rather than something under your feet. - A mesh's `drawInfo { ... }` block is read -- the corner table and the `tris`/`tristrip`/`trifan`/`quads`/`quadstrip`/`polygon` commands over it, its own vertex/normal/texcoord pools, its first `lod`, and `angvel` where BZFlag actually keeps it. This is what BZFlag *draws* a mesh with when one is present, and a mesh may state nothing else: every tank in `RatsNest.bzw` is written that way and used to draw here as nothing at all. As upstream does, the block feeds the screen and the `face` list feeds collision, so a mesh with no faces is decoration you drive through. - A spinning tank over the centre of `bzo.bzw`, turning once every eight seconds at three quarters of a jump's apex and wearing bzo's own untinted tank camo. It is BZFlag's stock tank, generated from this repo's own `public/obj/tank.obj` by the new `scripts/gen-tank-bzw.mjs`, and written in `drawInfo` form -- so the same text spins the same tank in a real BZFlag client. The idea and the name are Rat's Nest's, which hangs six copies of that same upstream model around its map; its geometry is not copied here, since that map is CC BY-NC-SA. - `scripts/gen-tank-bzw.mjs`, which turns an OBJ into a BZW mesh whose surface is a `drawInfo` block: pools deduplicated, a corner table over them, and `tris` commands over that. - One of `hix.bzw`'s support trusses in `bzo.bzw`, in the open ground south of the green base, carrying a round metre-thick platform at the height that map hangs a walkway from. It wears a hix walkway's own materials -- concrete deck, see-through grid underneath, caution stripe around the rim. - A row of panels west of the blue base in `bzo.bzw` showing each of these against its own control, and showing that a two-sided surface is written as two faces with reversed winding. ### Fixed - The largest mesh in `ahs3_Paradise_Valley.bzw` was being thrown away mid-parse. A `sphere` bounding hint inside a `drawInfo` block was read as the start of a sphere obstacle, discarding the mesh in progress and spilling ten thousand of its lines into the map's own "ignored" tally. With `drawInfo` read, every bundled map now loads with nothing reported. - A `material`, `physics`, `dynamicColor` or `textureMatrix` block written inside a `define` is read. BZFlag registers all four globally whatever they are nested in, and bzo was skipping them -- which left every `matref` naming one unresolved and the obstacle untextured. `tricolor.bzw` states seven of its materials that way and now draws with them. - `matref -1` and its `dyncol`/`texmat` equivalents are no longer reported as references to something missing. `-1` is BZFlag's own spelling of "none", and BZFlag suppresses the same warning for it. - A comment line in `hix.bzw` that had lost its `#`, which the map reported as a keyword bzo could not read. - The mountains are drawn over the ground rather than under it. BZFlag lays the ground down first and paints the mountains over it with the depth test off (`BackgroundRenderer.cxx:605-695`); bzo had the two the other way round, so the ground plane -- which reaches ten times the world, far past the mountain ring -- painted them out from any viewpoint high enough to see past it. On a small map that is one jump. - Map Viewer drops you inside the map. The position survived a world change, so arriving from a larger map -- or following a `?viewmap=` link carrying a `pos=` from a different map entirely -- left you outside the border wall looking in from the mountains. Only a starting position genuinely outside the new world is moved; roaming out past the wall on purpose still works. - The entry dialog's map picker lists every map, in alphabetical order. It was showing whichever maps the server had finished hashing at the moment that client connected and never revisiting the list, so a client that rejoined right after a restart could cycle the whole picker without passing maps that exist -- and the served map jumped to the front of the order regardless. The server now sorts the list and pushes the finished one to everyone already connected. ### Removed - `pyrtest.bzw`. Every pyramid case it covered -- the overlap trio, the nested footprints, `flipz` and the aliased spelling -- is in `bzo.bzw`'s own corner tests. ### Changed - `spintest.bzw` is a 100-unit world instead of an 800-unit one, and its blades are two-sided, so the thing under test fills the screen and does not vanish for half of each turn. - `groupAlpha` and `shader`/`addshader`/`noshaders` are read and dropped rather than counted as gaps. Neither can change what BZFlag draws: it collates a mesh's faces the way `groupAlpha` asks for regardless, and nothing in its whole tree ever reads a material's shader list back. - `noculling` draws both sides of a face built from a `drawInfo` block, and nothing anywhere else -- which is exactly where BZFlag can act on it. On every other surface BZFlag throws the face away when the eye is behind its plane, before the flag could disable anything; a map wanting a two-sided surface writes the face twice with reversed winding. ## [1.2.58] - 2026-09-24 ### Added - `/mv` takes an exact angle as well as a compass point, so the tail of a Share View Link pastes straight into it: `?pos=-320.0,0.0,-310.3,-91.3` becomes `/mv -320.0,0.0,-310.3,-91.3`. The number is bzo's own rotation in degrees, which is what the link writes -- not a compass bearing, so the two agree at 0 (north) and 180 (south) and disagree at the quarters. The reply still names the facing the tank ended up with, so a number that meant the other thing says so in the answer. ### Changed - The Overview camera frames the whole map instead of parking fifteen units up and twenty back from the origin whatever the world -- one building's worth of a 280-unit map and a patch of grass on an 800-unit one. The entry dialog's backdrop and the map picker's preview read the same framing, so choosing a map now shows you the map. Tilted rather than overhead, because the radar already draws this map from above and what it cannot draw is height. - Clouds are hidden while the Overview camera is up. The lowest cloud sits a jump above the tallest obstacle, which is tens of units where that camera is hundreds, so the layer stood between the eye and the map. - A grab, a drop and a theft say what happened once, in the coloured notice every player gets, instead of following it with a flat grey `local:` copy addressed to the player it happened to. Pausing reads the same way for everybody now, your own pause included. A bad flag still says how this world lets you put it down, since nobody else's line carries that. ### Fixed - Dying no longer costs you the Overview camera. The death camera used to share Overview's name, so the restore on respawn could not tell a player who had chosen Overview from one who had been put there by dying, and bailed to first person. It is its own camera now and nothing overwrites the view you picked. ## [1.2.57] - 2026-09-24 ### Added - The scoreboard has columns, and a heading over each one. `#` is the player number, shown to everyone -- upstream reveals its slot number only to an admin, but a bzo player id is already in every roster message a browser's dev tools can read, so the column is what makes "kick 3" and "watch 3" say the same thing to everybody. `Score` is the number upstream leads with, wins minus losses, which bzo sorted by and never showed. `BZID` is new and admin-only, for acting on an account rather than on a name that changes between sessions; the server omits the field entirely for everyone else. - The chat transcript scrolls under a finger. It stays out of the pointer's way while chat is idle so a drag there still reaches the battlefield, which is also why the browser never scrolled it: a touch listener now answers the same question the wheel already answered. - Tricolor, a three-team CTF map by The Purple Panzer. ### Changed - Chat shows three lines instead of six on a screen under 500px tall. A phone held in landscape is about 430px, where six lines plus the tabs and the input row stood a third of the way up the screen and into the targeting box. The whole scrollback is still there. - The chat tabs stick up out of the panel like the tabs on a manila folder, so the dark fill no longer runs the full width of the screen above the transcript and the battlefield shows through between them. - Team rows on the scoreboard are drawn in the radar's colours, which are lifted so a team reads against a dark panel -- purple in its tank colour was very nearly unreadable there. A team's row and its base on the radar now match. Each row names the colour alone, since every row on it is a team. - Shorter lines, for the same small screens: "Tim Riker dropped Red Team" rather than "... Red Team flag", "Orin joined", "Win11 paused", and "bzo.bzw ignored: ..." in place of "Keywords bzo does not read in bzo.bzw". The steal and capture lines keep the word, where it still does work. ### Fixed - Chat opens at its newest message after a reload rather than at the top of the transcript. The scroll position was pinned before the panel had settled, so the content grew underneath it and left the newest line below the fold. ## [1.2.56] - 2026-09-23 ### Added - Voice chat comes from the direction of the speaker. A peer's audio runs through a panner placed where their tank is standing, rather than playing unplaced on a hidden audio element. Only the Nearby channel is placed at the speaker's real distance and fades with it; All and Team reach across the whole map, so those are placed on the bearing to the speaker at the panner's own reference distance -- the direction, with the level left alone. The element stays as the fallback and keeps playing a peer until the graph has been proven to carry their audio, so a browser that cannot route a WebRTC track into Web Audio is unplaced rather than silent. - Game sound steps back 6 dB while somebody's voice is coming through, and comes back a moment after they stop. The player's Game volume is untouched underneath, and a silenced player ducks nothing. - The debug HUD names which stage is carrying each voice peer, and says when the game is ducked. ## [1.2.55] - 2026-09-23 ### Fixed - A tank's navigation lights are the same size to look at in a headset as on a screen. They were sized as a share of the drawing buffer's height, and the angle one pixel covers is the field of view over that same height, so the height cancelled and a light came out as large as the camera was wide: the game's 60 degrees across 16:9 is 36 degrees top to bottom, a headset's is 90-odd, and the same light was drawn three times the size at every distance and whatever the headset's resolution. The size now divides by the camera's own field of view, which also brings the tank preview's slightly closer camera into line. A light is also placed by its distance rather than by its depth -- depth falls short of the distance by the cosine of the angle off the view axis, which a 36-degree view keeps under a tenth and a headset's does not, so a light off to the side of vision was a third larger again and breathed as the head turned. - Applying a tank or team choice from the headset's settings panel no longer leaves the tank paused with no way to unpause it. The panel sent the join and then closed, and closing gives back the pause it took when it opened -- which landed behind the join, after the server had already cleared the pause for the new life, so the new life came up paused. Nothing could undo it: a pause the panel did not take is one the panel will not give back, and a headset has no pause key. The panel now closes before it sends the join. - A tank model whose parts are split by material is refused with the parts it is missing named, instead of being offered and then spawning an invisible tank. An OBJ `g` opens an object exactly as `o` does, so an export shaped `o body` followed by `g body_body_skin` carrying every face declares a body the loader never builds; the check the server makes before offering a model read only `o`, and only that a name was present rather than that any face landed under it. ## [1.2.54] - 2026-09-23 ### Added - Tanks carry upstream's three turret navigation lights: white astern, red to port, green to starboard, read the way an aircraft's are, so which way a tank is pointing resolves before its silhouette does. Upstream fixes them at one height above the stock tank's turret, which is inside the turret of half the models bzo offers, so each model places its own three as single-vertex `p` objects (`lightRear`, `lightPort`, `lightStarboard`) resting just over the surface beneath them; a model that names none falls back to upstream's coordinates. Each is one round, fogged, anti-aliased point, drawn from a three-vertex `THREE.Points` that costs one call per tank, and sized as a share of the drawing buffer's height rather than as upstream's flat two pixels -- which is only a size on the display it was chosen for, a quarter of its intended share of a 4K window and several times it on a phone. They follow the distance by its square root rather than by upstream's flat size or solid geometry's exact one, so closing on a tank grows its lights slower than it grows the tank: a far tank's lights are the largest thing about it, a near tank's are a detail on a hull already unmistakable. On a 1080p window that is two pixels across the map, four at ten tank lengths, nine at the chase camera, and twelve within a tank length. ## [1.2.53] - 2026-09-23 ### Added - A material's `alphathresh` is read and used as the alpha test the way upstream does. Where a map states none, bzo keeps its own low default -- without any test a foliage cutout's transparent pixels still write depth and block what is behind them -- but now says so, naming the texture that proved transparent, so the map can state one and render the same way on both clients. - Every keyword and server option a map states that no part of bzo reads is counted, named in the server log, and written into the map's own `-srvmsg` lines. A map asking for something bzo has not implemented used to be dropped in silence: `alphathresh` appeared 753 times across 25 of the maps in `maps/` without ever being mentioned. ### Changed - `maps/bzo.bzw`'s billboard bush states the material settings every other map using that same picture states -- `alphathresh 0.7` and the flags that go with it. Ours was the one map in a survey of live servers without them. - `scripts/survey-live-maps.mjs` imports through a running bzo and reports what that bzo said it could not read, instead of judging from a list of bzo's features kept in the script. That list had gone stale: it still called mesh, arc, cone, sphere, tetra, group, materials, physics drivers and texture matrices unsupported long after the parser read every one of them, and reported 60% of real map content as undrawable. - `docs/bzw.md` says what bzo does with a material's alpha, and that the properties it drops are not yet implemented rather than deliberately declined. Two of them -- `notexalpha` and `noculling` -- are cases where bzo does the opposite of what a map asked rather than merely ignoring it. ### Fixed - Editing a map or a model in development reloads it. nodemon watched those directories but not those file types, so an edited file kept being served from the copy the server read at startup, however hard the browser reloaded. ## [1.2.52] - 2026-09-22 ### Added - Two tank models, `bzfourtank` and a rebuilt `bzship`, contributed by @Aeonovyli. Thanks. Closes #114. - A model that arrives with no texture coordinates is wrapped when it loads rather than drawing flat: treads around their own belt loop, so the tread texture runs along the track, and every other surface on a sphere struck from the middle of the tank. A model that ships its own coordinates keeps them -- this rescues one that does not, and is no substitute for unwrapping a model properly. - The tank in the entry dialog runs its treads as it turns, and a wheeled model turns its wheels. ### Changed - Models load as they are wanted -- the selected one and the one either side of it in the carousel -- instead of the whole catalogue up front. A client was fetching 1.3MB and spending 710ms parsing models it might never wear, several seconds of it on a slow machine, and each model added made that worse for everybody. - The README says what is expected of an asset's licence. The code is AGPLv3; models, textures, sounds and maps come from several places and do not inherit that by sitting in the repository. Not everything already here meets the bar, and the section says so. ### Fixed - A tank part drawn with a material array cost a draw call per triangle. On the stock model that was twelve and nobody noticed; on a detailed one it was 4,735 and it halved the frame rate of every client that could see the tank. Closes #121. - The tank a player picked is kept across a reload. The stored choice was being held against the built-in model list before the server's list arrived, so choosing any model outside that list and reloading put you back in a bzflag tank -- and overwrote the choice. - The bzfourtank gun draws as a gun: its casing was named into `body` and took the camouflage skin, leaving a black bore inside a patterned tube. - Editing a model in development restarts the server, so the brotli sidecar is rebuilt. An edited model used to keep serving its previous version however hard the browser was reloaded. ## [1.2.51] - 2026-09-22 ### Added - The flag you are carrying says what it does, under the targeting box, for a minute after it changed hands -- upstream's own flag help, from the same strings the help panel is built from. In a headset it goes on the HUD plane just below the gaze axis, between the notices above and the chat below. The text cannot change while a flag is held, so the XR panel is painted when the help changes and left alone every other frame. Closes #112. ### Fixed - A burrowed tank casts no shadow. The planar shadow matrix offsets every caster vertex along the light by its own height and never collapses that to nothing, so a tank under the ground drew its full standing silhouette on the surface that was hiding it. Closes #115. ## [1.2.50] - 2026-09-21 ### Added - `/flag take <player>` and `/flag give <player> <#flagId|FlagAbbr> [force]`, upstream's own two. `take` sends the flag a tank is carrying back to a spawn point; `give` hands one over, resetting or dropping whatever the tank held, taking the flag off its current holder when `force` says so, and refusing a player who is not alive. `/flag drop` is unchanged and is still the one that leaves the flag on the ground where the tank is standing. ### Changed - `/flag show` prints upstream's own line per slot -- type, carrier, whether the slot is required, the grabs left in it and its status, alongside the position -- for every slot, empty ones included. The same reply now also fills the asking operator's own map in: a superflag nobody is holding travels anonymous, so the identities go out to that one client and its flags stop reading as unidentified. ### Fixed - `/flag up` now empties the world and leaves it empty, as upstream does. A required flag -- a zone flag, or one a map named -- came straight back the moment it was sent away, which made the command useless on exactly the maps it is for; it now waits for a `/flag reset`, while a pool slot refills on the insertion schedule. ## [1.2.49] - 2026-09-21 ### Changed - A remote map import now only fetches from a server that is in the public BZFlag list, so `/list`'s Import button, the in-game Import command and a shared `?viewmap=` link can no longer be used to make this server connect to an arbitrary host:port. A server that has since left the list is still viewable from the copy already imported here -- the check refuses the download, not the file. ## [1.2.48] - 2026-09-20 ### Changed - A menu row that walks a list now steps both ways under a pointer: clicking or tapping the row's title steps back through the options and clicking the current option steps forward, which is the same split the arrow keys and an XR thumbstick already read on the row. The Team and Map rows in the entry dialog and the Camera and Radar Range rows in Settings all follow it, a chevron on each half marks which way it goes, and rows with no list to walk -- the global login row -- no longer wear one. Closes #96 ## [1.2.47] - 2026-09-20 ### Fixed - HUD text that is too wide to fit now loses a whole character rather than half of one, so a truncated callsign, chat line or menu label holding an emoji or other non-ASCII character no longer ends in a replacement box. ## [1.2.46] - 2026-09-20 ### Changed - Other players' moves are relayed in one batched message per tick instead of one message per move, cutting WebSocket frame count under load. ### Fixed - The debug HUD's ping figure was always 0ms; it now shows the server-measured round trip. Closes #108 ## [1.2.45] - 2026-09-20 ### Fixed - **A ricocheting shot now bounces off a teleporter's frame instead of stopping dead or passing through it.** Hitting the solid border around a teleporter's doorway killed the shot unconditionally, regardless of the Ricochet flag or an all-shots-ricochet server, unlike every other obstacle; matches upstream, which treats a frame hit as an ordinary building collision. The client's own shot rendering had no model of a teleporter's frame at all, so any observer's view of the shot could disagree with the server outright rather than merely mishandle the bounce; it now traces and reflects the same way the server does. ## [1.2.44] - 2026-09-20 ### Fixed - **A shot fired point-blank into a thin wall no longer passes straight through to the other side.** A tank's muzzle offset was computed straight from its model's barrel geometry with no ceiling, so a long-barreled model could spawn a shot's origin past the tank's own closest approach to a wall; the "shot began already inside something" case that only makes sense right after a teleporter exit was then applied to that spawn too, waving the shot through untested. The muzzle is now capped at the tank's own closest approach, and that bypass only applies to a real teleporter exit -- so a tank pressed against a wall like hix's `xwall` now hits (and, with ricochet on, can shoot itself) exactly as it does upstream. Closes #83. ## [1.2.43] - 2026-09-19 ### Fixed - **A third-person camera clipping into a wall no longer blocks flag drop, reverse, jump, or firing for a tank that isn't actually touching it.** Those gameplay checks had been reading the same "inside a building" list the eighth-dimension see-through effect uses, which includes whatever the rendered camera position lands inside of; now the two are tracked separately, so gameplay only ever gates on the tank's own body. Closes #104. ## [1.2.42] - 2026-09-19 ### Changed - **A restart of the designated list server no longer waits on each registered server's own cadence to reappear on `/list`.** The signed validation callback now answers with the same status a report would have carried, and on boot the designated instance immediately re-validates every key checked in the last two days rather than waiting for the daily poll or the next push -- a few seconds instead of up to however long the slowest registered server's own reporting interval is. Needs both sides updated to take effect for a given server. ## [1.2.41] - 2026-09-19 ### Added - **The server name is now editable live from the Operator panel**, desktop and XR text entry both wired, the same as MOTD -- no restart, and it updates the entry dialog and this server's title on `/list` immediately. - **`/list`'s bzo-servers table gained Shots, Style and Voice columns**, matching what the bzfs table already shows plus one bzo-specific signal: Voice reports whether at least one ICE server is configured, since a voice connection across anything but a LAN typically needs one to work at all. - The two `/list` table headings read "Public bzo servers" and "Public BZFlag servers". ### Fixed - **A bzo server whose flags come from map zones rather than the `-s` pool now correctly reports having superflags** on `/list`. The option bit only checked the pool's count, matching upstream's own `-s` switch but missing upstream's own broader test for it (`CmdLineOptions.cxx`'s "does the resolved world have any flag beyond a team's own"), which zone flags satisfy too. - **Typing a space into the Server Name or MOTD field was impossible.** Both re-synced the field from a trimmed copy of itself on every keystroke, which snapped a just-typed trailing space back out before the next character could follow it. - `/list`'s bzo-servers table could show a stale snapshot -- missing a server that had already reported back in -- for up to 5 minutes after the designated instance restarted. Its own in-process read had no reason to be cached at all; only the cross-instance HTTP fetch does. - `sessions.json` and `list-server-keys.json` are now written pretty-printed, for anyone opening either by hand. ## [1.2.40] - 2026-09-19 ### Added - **bzo now has its own server list, since `my.bzflag.org`'s list server cannot validate a bzo server or dial its HTTPS/WebSocket connection.** One bzo instance is designated (defaulting to `https://bz.rikers.org`); every other instance registers a per-server key on it, reports over HTTPS/JSON on the same cadence bzfs uses (boot, ~15 minutes, join/part, and a REMOVE-equivalent on shutdown), and is validated by a signed HMAC challenge rather than a round trip of the raw key. Closes #46. - **`/list`** replaces `/view` and the standalone key-admin page: bzo servers, the public bzfs list, local maps, and this instance's own key admin, in that order, with a jump-link nav and this instance's login state at the top. `/view` and `/list-server` both redirect there. - The Operator panel gained a List Server Key row, admin-only and never echoed back to any other client. ### Changed - `/login` grew an optional `/login/list` form so a login started from `/list` returns there instead of to `/`. ## [1.2.39] - 2026-09-19 ### Fixed - **The sun's glow no longer draws in front of mountains, tanks, and everything else in the world.** It was on Three.js's transparent render list, which always draws after every opaque object regardless of draw order. Closes #102. ## [1.2.38] - 2026-09-19 ### Added - **The scoreboard now tracks a personal record against each opponent**: how many times you've killed them and how many times they've killed you, shown as `wins~losses` beside their row and dropped first on a phone-width screen. Your own row shows a self-destruct count instead, matching upstream's `Player::localWins`/`localLosses` and `getSelfKills()`. Closes #65. - **A player's row shows 🔊 instead of 🎤 while their voice is actually audible**, not just enabled, from a live read of their incoming audio level. - The in-game Help panel documents the scoreboard's stats format and every glyph drawn next to a name. ### Changed - Chat lines now colour the named player in their own shade in every message kind, not just team and admin messages, and add the rabbit mark where it applies. The redundant `(Team)` suffix upstream's notices carry is gone from bzo's, since the name is already coloured there. - Default voice channel is now **All** rather than **Nearby**. - Roam leader-follow is recomputed only when the scoreboard's own model changes (a join, a leave, a kill, a flag), not once a rendered frame. ### Fixed - **A Bouncy tank stopped bouncing, and an airborne tank's rotation froze, the moment its pilot opened chat.** Both are physics that has nothing to do with the keyboard, but shared an input-context check with the code that reads it. Closes #95, #99. ## [1.2.37] - 2026-09-19 ### Added - **A material's `specular`/`shininess`/`emission` now shade a face**, matching the three of `BzMaterial`'s four lighting coefficients that reach real GL state upstream. A face with real specular gets a proper highlight; `emission` gives it a self-lit tint. `ambient` is read but never applied, matching upstream's own dead field -- nothing in the whole upstream tree ever calls `glMaterial(..., GL_AMBIENT, ...)` either. Closes #89. ### Fixed - **A tank's name could z-fight with a distant mountain and flicker behind it.** A mountain sits far enough out that reaching it stretches the camera's far plane, thinning the depth buffer's precision for everything nearer. Matching upstream's own "don't do zbuffer test" for mountains fixes it. Closes #92. - **The admin whitelist's startup self-check now retries before giving up.** A live game loop shares this process's one thread, so the probe's timeout can lose a race to a busy moment right after boot, leaving every proxied admin -- whitelisted or not -- refused for the rest of that run. ## [1.2.36] - 2026-09-19 ### Added - **A mesh can spin.** `angvel <degrees/sec>` turns a mesh continuously about its own placement -- upstream's `MeshDrawInfo` animation, previously decoded from a remote import and reported as something bzo couldn't play (#87) but now applied, in the 3D view and on radar alike. Purely visual: a spinning mesh's faces still collide and block shots as if they never moved, matching upstream's own zero-face-drawInfo behavior. Closes #88. ### Fixed - **A remote import of a map with `nowalls` built bzo's own default border wall anyway.** Upstream never sends a `wall` obstacle at all once a map's own `world` block says `nowalls` -- an empty `wall` list on the wire is the only trace that flag leaves once compiled, and a remote import never checked for it. - **A `mesh` obstacle -- a drawInfo tank, a beam, any other mesh block -- cast no shadow.** Box and pyramid obstacles already project one every frame off the same shared stencil pass; `mesh`-type obstacles were simply never in that loop. ## [1.2.35] - 2026-09-18 ### Added - **Loading any map -- not just a remote import -- now tells you, once, about everything bzo doesn't process: unresolved references, dropped shapes, a mesh's own spin animation, non-default material lighting, which hosts its textures came from, and so on.** One terse `<map> <label>: <content>` line per thing, the same line whether it reaches server.log or a joining player's own chat -- previously several of these were console-only, and the ones that did reach a player were each worded separately from their own console line. ### Fixed - **A mesh built with BZFlag's MeshDrawInfo render optimization now actually renders, and shows on radar.** It used to import with real vertices but zero faces -- invisible and non-collidable -- because the hidden drawInfo blob packed into its texture-coordinate region was never decoded. Closes #87. - **A map's `GroundMaterial` texture matrix and dynamic color now reach the ground plane.** Read correctly since the ground got a material at all, but never wired to the renderer -- a floor tiled with a `texmat` fixedscale (a common way to repeat a texture more densely than its raw UVs) looked stretched compared to upstream. - A remote import no longer logs every one of a map's own warnings to server.log twice. ## [1.2.34] - 2026-09-18 ### Added - **Importing a remote map now tells you what it couldn't bring in.** A feature bzo doesn't process yet -- a mesh built with an optimization bzo doesn't reconstruct, an unresolved material reference, and so on -- used to only ever reach server.log. It's now written into the imported map's own file as `-srvmsg` lines, so anyone who joins or views that map sees it on arrival. - `/view`'s local maps table now shows each map's registry hash, last- modified time, and `.bzw`/`.json` size. ### Fixed - **Importing a world from the server list works again against a host advertising both an IPv4 and an IPv6 address.** Upstream bzfs has no IPv6 support; bzo could still end up dialing the IPv6 address and hanging. Outbound connections to a remote bzfs are now forced to IPv4. - **A curved obstacle's (`arc`/`cone`/`sphere`/`tetra`) `shift` line is no longer ignored.** A map that places several of these at the same `position` and relies on `shift` to spread them apart -- a common way to build a border out of repeated pieces -- used to render them all collapsed on top of each other. - **A material with a transparent `diffuse`/`color` (alpha 0) is now actually invisible instead of solid black.** A common mapping trick -- geometry that's solid for collision and shows on radar but draws nothing -- was rendering as an opaque black wall. - A generic mesh face with no texture at all no longer falls back to the boxwall texture; it now renders flat-colored, matching what the map actually specified. - Fixed a race where two overlapping requests to import the same remote map could each write their own copy of the new import-warning messages into the map file. - Log messages (console and server.log) no longer include this server's own install path. ## [1.2.33] - 2026-09-18 ### Changed - **`OO` (Oscillation Overthruster)'s inside-a-building glow is now the same everywhere.** Upstream draws it two different ways -- a real-texture, inside-out glow for a mesh, an unrelated scattered dot cloud for a box or pyramid -- and bzo used to match that split. Every obstacle now gets both: the real geometry glowing from the inside, plus a scattered cloud (a mesh's own version ray-casts candidate points against its triangles, since it has no simple footprint to sample). The effect also now follows wherever the camera actually is, not just the tracked tank's own hitbox -- third-person and an observer's follow-leader view put the eye at a fixed offset with no wall-avoidance, so backing into a wall could show broken geometry for a moment before the effect caught up. ### Fixed - **The interdimensional-light streaks `OO` draws on entry/exit now work on a mesh wall, not just a box or a pyramid.** The crossing-plane math behind them only ever understood a box's own width/depth. - **A wall shared by two overlapping obstacles, or a mesh's own floor, no longer glows through itself.** The new inside-out glow above skips a face already known to be buried against a neighboring obstacle, and a mesh's ground-level floor (never drawn from outside, so often left with whatever texture was already lying around) is skipped outright rather than additively blended over the real ground it z-fought with. ## [1.2.32] - 2026-09-18 ### Fixed - **`CL` (Cloaking) no longer hides you from radar too.** The radar drew no blip for any tank whose 3D mesh was hidden, and cloak's own fade-out hides that mesh once alpha reaches zero -- so a cloaked tank silently got `ST` (Stealth)'s radar immunity for free, on top of the "invisible out the window" `CL` actually grants. Radar now checks health and Map Viewer alt-world preview directly instead of the mesh's shared visibility flag. ## [1.2.31] - 2026-09-18 ### Added - **`localAdmin` can now cover more than this machine's own loopback address.** `adminWhitelist` in `server.json` names extra addresses or CIDR blocks (IPv4 or IPv6) to also grant admin, trusted only once a startup probe confirms it is safe to: with `publicUrl` configured, the server calls itself through real DNS and whatever reverse proxy sits in front -- once plain, once with a poisoned `X-Forwarded-For` -- and only reads the header at all if that poisoned value cannot survive as the proxy's own last hop. A proxy that cannot be verified, or none configured, leaves the whitelist limited to unproxied loopback, exactly as before. ### Fixed - **A tank's nametag no longer stretches with Tiny, Narrow, Obesity, or a landing squish.** The nametag sprite is a child of the same group the tank's own body scale is set on, so it inherited that scale too; it now counters it back out each frame, on both the tank and its debug ghost. ## [1.2.30] - 2026-09-18 ### Added - **Maps can now set a custom ground texture (`-gndtex`), matching upstream.** A map's `options` block may state `-gndtex <name>`, or an explicit `material name GroundMaterial ... end` block, either a stock texture name or an external URL, the same as any other material. The ground plane textures and tints from it, falling back to the stock `std_ground` checkerboard when a map states neither. `hix.bzw`'s ground now matches its own box/octagon/walkway tops via `matref concrete`. See "Ground texture" in `docs/bzw.md`. ## [1.2.29] - 2026-09-18 ### Fixed - **Pyramid orientation now matches upstream for a textured pyramid.** Any pyramid with a per-face command (`sides matref`, `texsize`, a face's own texture or color, `phydrv`) now follows BZFlag's real `isOldPyramid=false` construction: a negative height anchors `position` at whichever end it points to rather than literally flipping the shape, matching what a real bzfs client renders for the same map instead of always applying the legacy flip interpretation. - **A tank on one mesh's flat roof is no longer blocked by a neighboring mesh's wall at a shared seam.** Collision now collects every mesh face a move actually touches before picking a winner -- any flat top beats any wall, matching upstream's own tie-break -- instead of stopping at whichever obstacle happened to answer first. Also ported upstream's own stuck-frame escape for the remaining case of two obstacles pinched too tight to slide out of. - **A flag dropped on a mesh's flat roof now behaves the same for every player.** The footprint test a flag drop uses had mesh support on the server only; a real client now agrees with it instead of always reading a mesh roof as not-a-valid-drop-spot. ## [1.2.28] - 2026-09-17 ### Added - **BZFlag's weather map feature (`_rainType`).** A map's `-set _rainType` now draws upstream's rain, snow, fatrain, frog, particle or bubble -- drops that fall (or, for bubbles, rise) from the sky, stop at the first roof beneath them rather than falling through, and puddle where they land, growing and fading over a few seconds. One instanced-mesh rendering path carries every preset rather than upstream's three (a `GL_LINES` streak, a billboard, and a non-billboard "cross" of three quads), and roof culling reuses the shot-collision raycast rather than porting upstream's own octree. `maps/weather.bzw` previews it through Map Viewer, no live match needed. See "Weather" in `docs/bzw.md`. ## [1.2.27] - 2026-09-16 ### Fixed - **`bzship.obj`'s barrel had four stray loose-edge (`l`) lines at the tail of its object block.** three.js's `OBJLoader` sets a whole object's type to `LineSegments` the moment it sees even one, and never sets it back -- so despite carrying thousands of real faces, the barrel built as something the renderer's part lookup could never find, and the tank failed to build for every client. Removed the four lines; the model was otherwise sound. ### Changed - **The tank model picker now rejects a part tainted by a loose edge or point, not just a missing one.** `readObjObjectNames` previously reported every `o`-declared name regardless of what else was in its block, so the server's own buildability check missed exactly the defect above and offered the model anyway. A model with this defect is now excluded from `/api/tank-models` the same way one missing the part outright would be. The ship model still has open rough edges tracked in #59 -- most visibly, its first-person eye height sits inside the hull rather than above it, and its tread meshes read as full-height side skirts rather than a track band, so it doesn't yet look or feel finished. ## [1.2.26] - 2026-09-16 ### Added - **BZFlag's `waterLevel` map feature.** A map can now flood part of its world with a translucent, scrolling water plane; a tank that drops to or below the surface is destroyed ("fell in the water" / "Tank Rusted"), matching upstream's own rule exactly. Spawn points, flag spawns, and a dropped flag's landing spot all stay off the water's surface, the same as a real bzfs. `maps/water.bzw` is a new four-base CTF map floating above the water, jump-connected island to island. ## [1.2.25] - 2026-09-16 ### Changed - **A remote server's imported map (`?viewmap=import-<host>_<port>.bzw`) no longer accumulates on disk forever.** Its one-hour reuse window only ever decided whether to re-fetch it, never whether to delete it, so a server that had ever imported a few dozen remote worlds kept every one of them indefinitely. The background map-hashing pass now also runs periodically rather than only at boot, and deletes an import twice its reuse window past its last real fetch (read off the file's own last-modified time, not an in-memory timestamp a restart would otherwise reset to "now" for every file already on disk) -- along with its cached JSON and brotli sidecar, closing a gap the existing brotli cache's own sweep can't reach on its own once a server has been running a while. ## [1.2.24] - 2026-09-16 ### Added - **The scoreboard now shows a paused marker (⏳), a live microphone indicator (🎤), and a team-kill count per player**, restoring columns upstream BZFlag's own scoreboard carries that bzo's never did. bzo draws to a real DOM rather than a fixed bitmap font, so these are real glyphs instead of upstream's `[p]`/bracket text, and the team-kill count only appears once there is one to report -- a zero is the expected state for almost every row, not information. - **The debug HUD's voice section reports what actually happened to a connection**, not just whether it exists: the real ICE candidate type in use (host/relay/etc, in place of the redundant word "connected"), whether real audio has been confirmed sent and received (`getStats()`'s packet counters), and whether the peer's `<audio>` element is actually playing -- the combination that made a longstanding "nobody's mic works" bug findable at all, instead of every connection quietly looking fine. ### Fixed - **Voice chat audio essentially never reached anyone.** A new WebRTC peer's sender always starts with `addTransceiver`'s own null track, and nothing ever attached the real one for a peer that appeared *after* the local mic was already turned on -- so a connection only carried real audio in the narrow case where the mic happened to be toggled after every peer already existed. The current track is now attached the moment a peer connection is created. - **A dropped voice connection now attempts an ICE restart before giving up.** A lost consent-freshness check or a lossy stretch of network isn't proof the two peers can no longer reach each other, and `restartIce()` is the documented recovery for it -- re-gathering candidates and re-running connectivity checks on the same connection and tracks, rather than the full teardown-and-renegotiate-from-zero a closed connection required before. - **Gameplay sound could pile up and burst-play all at once** after bzo's own automatic client reload. The browser's audio context starts suspended until a user gesture resumes it, and reload's silent auto-rejoin can start delivering sound events before the player has clicked anything on the fresh page -- so a sound fired during that window scheduled at a frozen clock and only actually played, along with every other one queued behind it, the moment an unrelated later click resumed the context. The context now resumes on the very first interaction of any kind, and any sound that still fires before that is dropped rather than deferred. - **The persistent voice-channel HUD widget always said "Nearby"**, no matter which channel was actually selected -- three separate places in its update function had the label hardcoded rather than reading the player's actual choice. ## [1.2.23] - 2026-09-16 ### Fixed - **A world file's URL is now validated before it is fetched.** GitHub code scanning (CodeQL `js/request-forgery`) correctly traced a path from the raw WebSocket message all the way to `loadWorldFile`'s `fetch(worldRef.url)` with nothing in between enforcing its shape. Every world URL a client is ever handed is one `server.js` itself built from a content hash (`/maps/<hash>.json`), never an arbitrary string -- that is now checked explicitly instead of trusted implicitly, so a future bug, a compromised server, or a spoofed connection can no longer make the client fetch anything else. ## [1.2.22] - 2026-09-16 ### Added - **A `?viewmap=import-<host>_<port>.bzw` link now outlives its own cache.** Previously the link only worked while that exact file was still registered; once bzo let its cached copy of a remote server's world go (or never fetched it in this process), the link silently fell through to an ordinary join with no explanation. The filename already names the server it came from, so the client now asks the server to (re-)import it on demand, shows the loading overlay while that settles, and finishes the join as Map Viewer on success or an ordinary join with a HUD alert on failure. An import less than an hour old is reused rather than re-fetched. (#73) - **Every stock texture name upstream ships an asset for is now usable in a material**, not just the handful most real maps reach for (`boxwall`, `roof`, `pyrwall`, ...). A map maker has no way to predict which texture bzo has an asset for, so all of upstream's remaining stock textures -- every team-tinted bolt/laser/tank/base skin, the `mesh` grid texture, `snowflake`, `water`, and more -- were copied in and made resolvable the same way the originals are. ### Changed - **The entry dialog's name field locks to a verified session's forum callsign.** Signing in with a bzflag.org global callsign now fills and disables the name field immediately, including right after the redirect back from weblogin -- previously the verified state only reached the dialog after a join had already happened, so a returning verified player could see (and edit) the wrong name for a moment. Logging out restores the field to editable. (#75) - **server.log no longer logs every cached map's quirks on every restart.** Unresolved refs, external texture URLs, dropped spins, and broken teleporter links used to print for every file the background trickle re-hashes at startup, not just the live map -- so a server carrying a few dozen cached remote imports filled its log with quirks about maps nobody was playing. The live map still logs loudly; the trickle is quiet and prints one summary line instead ("Converted N of M bzw file(s) to cached json"). The live world's own obstacle dump now logs where its cached JSON lives rather than inlining the whole array as one multi-hundred-KB log line. - **WebXR's three startup debug lines are now one.** The launch-session report and the XR support probe used to log separately on every page load; they now combine into a single debug line. ### Fixed - **Oscillation Overthruster no longer lets a phased tank jump out of a building.** Matches upstream: jumping requires standing on the ground or a building (Wings excepted), so a tank that has driven through a wall with OO can't jump its way out even on a server that allows jumping. (#76) - **Border walls no longer overlap at the corners or draw faces nobody sees.** Each wall now runs exactly `worldSize` long and meets the perpendicular wall at the corner instead of overlapping past it, and drops its end faces, top, and bottom -- matching upstream's thicknessless single-quad border, down to a single visible quad per wall. (#74) ## [1.2.21] - 2026-09-16 ### Fixed - **The v1.2.20 tag was cut without `npm run release:prepare`**, so `public/version.mjs`'s own `CLIENT_VERSION` was never bumped and the release workflow's own version check correctly rejected it. No functional change from v1.2.20 -- this release exists only to carry the correct `CLIENT_VERSION`, and v1.2.20 stays tagged rather than moved, per this project's own rule for a release that fails before publishing anything. ## [1.2.20] - 2026-09-16 ### Added - **`dynamicColor` and `textureMatrix` are now read** (`dyncol`/`texmat` on a `material`) -- upstream's animated tint and scrolling/rotating UV keywords, the last item under "Materials and appearance" in `docs/bzw-plan.md`. Both blocks resolve into a named registry the same digit-first-then-name way `matref`/`phydrv` already do, and read everywhere `matref` does: material blocks, `matref`'s own wholesale copy, mesh faces, group-instance overrides, and a box/pyramid's own wall/cap lines. The per-channel colour blend and per-frame UV-matrix math are ported directly from `DynamicColor::update`/`TextureMatrix::update` (`src/game/DynamicColor.cxx`, `src/game/TextureMatrix.cxx`), validated against three real maps that use both (`ahs3_Ironside_Battlefield.bzw`, `dw_missilewar3.bzw`, `import-Planet-MoFo.com_4202.bzw`). Every connected client evaluates the same phase at the same real moment (`Date.now()`, not a per-tab clock), matching upstream's own network-corrected `GameTime`. - **`maps/bzo.bzw` demonstrates both**: a scrolling caution-stripe sign and a colour-cycling beacon sign (mesh faces), and the `PD_Conveyor`/ `PD_Death` physics-driver pads now carry a matching scrolling stripe and pulsing beacon of their own -- the conveyor's scroll speed is derived from its own `linear` driver speed and the box's own texture tiling, so the two visibly agree (upstream has no engine-level link between a physics driver and a texture matrix; this is the same manual tuning any mapper would do). ### Fixed - **A foliage-style alpha-tested material no longer occludes itself through the depth buffer.** A billboard bush's own crossed quads were each writing real depth, so whichever quad came first in the vertex buffer clipped the others instead of showing the layered look upstream does. `depthWrite` is now off for any alpha-tested material, matching upstream's own blanket `glDepthMask(GL_FALSE)` for its whole transparent render pass. - **The debug ground grid drew over foliage and other transparent scenery instead of under it**, once the above changed depth-writing for transparent objects -- its own `renderOrder` was higher than ordinary scenery's default, backwards from what it needs. Now sorts first in the transparent pass, below everything else. - **A teleporter's own portal glow scrolled backwards from a real client.** It was approximated with a hand-tuned `texture.offset` animation rather than upstream's real `textureMatrix` transform -- a different formula, not just a different constant -- so bzo's own guess at the scroll direction didn't actually match. Now runs the same `applyTextureMatrix` a mapped `texmat` uses, with upstream's own real default (`shift 0 -0.05`). - **The `PD_Conveyor`/`PD_Death` physics-driver test pads sat only a few metres apart in the open field**, an easy way to get killed by accident. Moved to the west wall, with real spacing between them. ## [1.2.19] - 2026-09-15 ### Added - **`physics` / `end` blocks are now read, and `phydrv` actually does something.** `linear` adds a velocity to a tank resting on the surface naming it (a jump pad, a conveyor, an elevator, all the same mechanism in different directions), and `death` kills a tank outright with the mapper's own message. `angular`, `radial` and `slide` are recognized but not wired to motion -- no real map among the 21 sampled to validate this uses any of the three. `phydrv` now resolves on a box, pyramid, group instance, or mesh face, the same digit-first-then-name lookup `matref` already gets. - **A remote map import now records more of what the source server actually reported.** Game style (`-c`/`-offa`/`-rabbit`), `-handicap`, `-mp` (team limits, from the list server's own reported maximums), and the source server's title (kept as a comment only, so re-serving the file never publishes it under that name unexpectedly). A world reporting Antidote or Shakable without an explicit flag count now gets bzfs's own implicit default (`-s 16`) written in, since a world can't have either without having some bad flags to shake. - **A `mesh_billboard`-based bush** in `bzo.bzw` -- eight quads at the same spot, rotated 22.5 degrees apart and alternating a mirrored texture, in a nested `group` rather than eight repeats of the same `shift`. - **Foliage-style textures with real transparency now blend instead of rendering solid**, on both a plain obstacle and a mesh face -- detected by scanning the loaded image for any non-opaque alpha byte, the same check upstream's own `OpenGLTexture::getBestFormat` makes. A low `alphaTest` (0.05) discards only the fully-transparent gap in a cutout, so a deliberately partial texture (a mapper's own 20%-alpha glass, an 80%-alpha overlay -- both real examples this was checked against) keeps blending and keeps occluding normally rather than vanishing or turning solid. ### Fixed - **A `death` physics driver never actually killed anyone**, even standing right on top of one. The server had no equivalent of the client's own "what am I resting on" tracking, so it stood in with the anti-cheat penetration test (`checkCollision`) -- which is deliberately built to treat a tank resting exactly on solid ground as *clear*, the opposite of what a support check needs, and which also skips a `driveThrough` face outright, exactly the kind of surface a death floor or a river conveyor is. A new `findPhysicsSurfaceObstacle` (`server/collision.cjs`, `public/collision.mjs`) matches upstream's actual mechanism instead (`LocalPlayer::getHitBuilding`/`collectInsideBuildings`): an inclusive touching test, independent of `driveThrough`, run only for this purpose. - **Jumping no longer defaults to on.** bzo used to assume jumping was allowed unless a server explicitly turned it off; a real bzfs requires `-j` before any tank can leave the ground, and bzo's own default now matches -- `server.json`'s `jumping: true` (or a map's own `-j`) is what turns it on, not the other way around. - **A `group` instance's `matref`/`phydrv` now override a mesh member the way upstream's own `ObstacleModifier::execute` actually does**, not the "only if the member set none" rule this carried before: a material override replaces every face's texture unconditionally, while a `phydrv` override only ever replaces a face that already named some driver. Neither ever touches a plain box or pyramid member. Confirmed against a real map's own elevator group that depends on exactly this. - **A mapper-named external texture URL now loads from any host**, not just a small built-in allowlist. bzo has no equivalent of upstream's local, per-player `DownloadAccess.txt` -- there is no channel for an operator or a mapper to ask bzo to trust a host -- so a fixed allowlist could only ever be bzo guessing on their behalf. Documented as a deliberate deviation from upstream in `AGENTS.md`. - **Every obstacle now carries a precomputed world-space bounding box**, mesh included (a mesh already had one from its own vertices; box, pyramid, teleporter and base now get one too, from upstream's own rotated-rectangle formula), used as a broad-phase reject before any narrow-phase collision test -- a bare distance check that was already skipping boxes far from a query point now does the same for meshes, which it previously always had to test in full. - **Cloud height and a debug collision log line both had the same "mesh has no `.h`" blind spot.** Cloud placement used to default a mesh's height to a flat 4 regardless of its real size, sitting clouds inside tall trees on some real maps; the collision debug log crashed outright calling `.toFixed` on a mesh's nonexistent `.x`/`.z`/`.rotation`. Both now read a mesh's own `.bounds` instead. ## [1.2.18] - 2026-09-15 ### Fixed - **A `matref` naming a `material` block with no `name` line of its own now resolves.** Upstream lets a mapper reference a material purely by its position in the file (`matref 0`, `matref 17`, ...) when it never gave the block a name -- `BzMaterial::findMaterial` checks for a leading digit before it ever tries a name match. bzo only ever registered a *named* material, so every numeric reference was silently dropped. Real maps lean on this heavily: `import-bz4.rikers.org_5154.bzw` alone has 18 materials, all but one unnamed, referenced purely by number. ## [1.2.17] - 2026-09-15 ### Added - **`arc` and `meshbox` are now supported BZW primitives** -- upstream's own same generator built two ways (`CustomArc.cxx`, `meshbox` just constructed with `box=true`). Unlike `cone`, an arc never tapers, and its own `ratio` (0..1, between inner and outer radius) branches the whole shape in two: the default `ratio=1` collapses to an ordinary solid wedge or disc, anything less is a genuinely hollow tube with its own `inside` wall, six materials (`top`/`bottom`/`inside`/`outside`/`startside`/`endside`), and quad faces throughout rather than triangles. - **`sphere` is now supported too, completing all six BZW primitives that expand to a mesh.** Recursively subdivides a quarter-sphere into a triangular grid from pole to equator, mirrored four ways around for the full circle and top-to-bottom for a complete sphere -- or closed with a flat disc instead, for `hemisphere`/`hemi`. `radius` sets all three axes of `size` at once for a true sphere; a plain `size x y z` still gives an ellipsoid. ### Fixed - **A `meshpyr`/`meshbox`'s own `rotation` was silently doing nothing.** Upstream applies a mapper's rotation on these two as a genuine second transform stacked on top of the fixed 45-degree twist that turns the underlying cone/arc generator into a square footprint, not folded into the sweep angle the way a plain `cone`/`arc`'s rotation is. `bzo.bzw`'s own `test_meshbox` fixture had been silently ignoring its own `rotation` line since `meshpyr` first shipped. ## [1.2.16] - 2026-09-15 ### Added - **`tetra` is now a supported BZW primitive.** Parses its four vertices and per-face (or whole-tetra) materials, corrects the vertex winding the same way upstream's own `TetraBuilding::checkVertexOrder` does, and builds a real four-face mesh through the existing mesh render/collision/radar/debug pipeline unchanged. - **`cone` and `meshpyr` are now supported too** -- `meshpyr` is upstream's own same generator (`CustomCone.cxx`) built with `pyramid=true`, not a second implementation. Position, size, rotation, a `divisions`-sided sweep (`angle`, defaulting to a full circle), per-face materials (`edge`/`bottom`/`startside`/`endside`), `texsize`, and `meshpyr`'s own 45-degree twist, sqrt(2) footprint scale, and `flipz` are all read. Generates real explicit per-vertex texcoords (upstream's own wrap-around math, so a texture wraps around the curve cleanly instead of fanning out) and real smooth per-vertex normals where `useNormals` asks for them. - **A tank phased through with Oscillation Overthruster can now stand inside a mesh and see it.** Previously a mesh was never even detected as something a phased tank could be inside of, and the effect's own geometry builder assumed a box or a pyramid's `w`/`d`/`h` fields, both `undefined` for a mesh. Now built the way upstream's own `EighthDimShellNode` draws a mesh specifically (not the box/pyramid random-point-cloud effect at all): the mesh's own real faces, redrawn inside-out and additively blended, with a wireframe outline over them. ### Fixed - **A ricocheting shot could pick the wrong face of a mesh and reflect wildly** -- a laser bouncing off a `cone`/`meshpyr`/`tetra` could shoot off at a near-vertical angle and leave the map almost instantly. Root cause was two stacked bugs in how a mesh's face is chosen for a shot's exact crossing point: a 2D-projection point-in-polygon test that could accept a point nowhere near a thin, steeply angled face's real area, and a radius-offset point that could drift past a face's own true edge at a shallow angle. Both are now ported directly from upstream's own `MeshFace::intersect` mechanism (per-edge "fence" planes, tested against the ray's exact un-offset crossing). - A mapper-hosted texture URL now round-trips through both a real bzflag client and bzo's own: write `http://` in the `.bzw` file (confirmed against a real client, which only reliably follows that scheme, not `https://`), and bzo's server now strips the scheme down to protocol-relative before forwarding to its own browser clients, avoiding mixed-content blocking on an `https://` page like bz.rikers.org. ## [1.2.15] - 2026-09-15 ### Added - **Map Viewer camera and position parameters, and a Share View Link button.** `?viewmap=map.bzw` now also takes `cam=free|fp|tp` and `pos=x,y,z,rotation`, so a link can drop a viewer at a specific spot looking a specific way instead of always starting in free roam at the map's own spawn. A "Share View Link" button in the settings menu builds one from the current map, camera and position and copies it to the clipboard. - **The radar shows the local player's own position.** Previously only other players drew a blip; an observer or a Map Viewer session watching an empty map had nothing to orient by. The local player's own marker is now exempt from the health/visibility gates that otherwise hide an observer's invisible virtual tank. - **`bzo.bzw`: a real mesh octagon (`Octagon`/`mesh_octagon`), placed through a `define`/`group`, matching the existing four-box-plank octagon's exact footprint and height** -- a direct A/B test of mesh vs. box collision at the same shape, and a non-rectangular collidable mesh for testing the oriented tank box against (part of #72). - **The six BZW primitives bzo does not parse yet -- `arc`, `cone`, `sphere`, `tetra`, `meshbox`, `meshpyr` -- added to `bzo.bzw`** for comparison against a real bzfs/bzflag. bzo's own "features this map uses that it can't render yet" tally now counts all six (`meshbox`/`meshpyr` were missing from that list before). ### Changed - Self-Destruct moved from `Q` to `Delete`, matching upstream BZFlag's own default binding. - Switching camera mode (free roam to first/third person and back) now preserves position -- previously, dropping out of first/third person resumed free roam from wherever it had been parked *before* driving started, rather than where driving actually left the tank. ### Fixed - **A mesh's per-face `drivethrough`/`shootthrough` are now actually checked.** `parseBZWMap` always read them onto each face, but nothing consulted them until now -- a tank could be stopped by a face marked `drivethrough`, and a shot could ricochet off one marked `shootthrough`. - **A shot could pass straight through a thin mesh face.** An ordinary shot's per-tick collision check only asked whether that tick's own endpoint landed inside a target -- fine for a box or a pyramid's real volume, but a mesh face has none, so a normal-speed shot could step clean over its narrow catch band. Replaced with an exact ray-vs-face crossing test (matching upstream's own `Obstacle::intersect`, a real ray/geometry intersection rather than a discrete sample), used by both an ordinary shot's per-tick path and the Laser's own whole-lifetime segment. - **A tank could get stuck motionless driving off a mesh edge, or sliding along one at an angle, rather than falling or continuing to slide.** Checked upstream directly: `World::hitBuilding` never accepts a face as a blocking hit unless it is a flat top/bottom or the query is actually moving into its outward normal -- bzo had this backwards, accepting the geometric overlap as the hit first and only checking the normal afterward, by which point the search had already stalled. Two related cases surfaced fixing this: the hit-normal lookup could disagree with the search right at a mesh's own corner (querying a few thousandths of a unit off from where a touch was actually confirmed), and a flat top/bottom's own "always blocks" exemption (needed so resting still holds a tank up regardless of horizontal velocity) did not check whether the query was actually within that face's own footprint, so it could re-block a direction a wall had already, correctly, let through. - Debug Geometry's support-surface outline now covers a mesh obstacle -- previously silent (a mesh has no `w`/`d`/`rotation` for the box/pyramid reconstruction this reused) rather than broken, but drew nothing either way. ## [1.2.14] - 2026-09-14 ### Added - **BZW `shift`/`scale`/vertical `spin` (part of #72).** A `group` instance may now state these directly instead of only `position`/`size`/`rotation` -- `shift` and a vertical `spin` are read the same way on a plain box/pyramid/teleporter too, a pure translation or a yaw being exact either way it is spelled. A `spin` about any axis but the vertical is named on load rather than silently misapplied, since bzo's box/pyramid model has no way to tip a shape that way. - **Mesh geometry: parsed, textured, and placed through groups (issue #72).** A `mesh` block's full grammar now reads -- vertex/normal/texcoord pools, `face`/`endface` with per-face `matref`/`phydrv`/passability -- validated against all three wiki.bzflag.org reference examples and against real community maps' own mesh content. `render.js` builds and textures each one, auto-generating planar UVs for a face that states none of its own (upstream's own `makeTexcoords`) so a stock texture like `boxwall` tiles instead of washing the whole face in one sampled pixel. A `define`'s own meshes are now placed by a `group` instance the same recursive way its box/pyramid members already are. `maps/bzo.bzw` carries one of each of the three reference shapes as a permanent fixture. - **A tank and a shot both collide with a mesh, face by face (issue #72).** Upstream tests a mesh face as its own independent flat polygon, never as one enclosed volume -- which is exactly why a concave mesh needs nothing special, upstream or here. `meshIntersectsCylinder` ports upstream's own `testPolygonInAxisBox`/`projectAxisBox`/`projectPolygon` (a real separating-axis test, not an approximation), guarded by a whole-mesh bounding-box reject before any one face runs. A tank stops at a mesh wall; a shot stops at or ricochets off one, the same square-footprint approximation upstream's own `MeshFace::inCylinder` gives a "circular" occupant. ### Fixed - A flush, coloured box no longer confuses a real `bzfs`. Any material property on a box or pyramid -- a bare `color` included -- switches upstream off its fast, always-valid path and onto one that builds eight explicit corners; at exactly zero height that degenerates into four zero-area side faces, which upstream drops with an "invalid mesh face" warning. `bzo.bzw`'s four team-coloured spawn pads hit this exactly; each is `0.01` tall now instead of perfectly flush, visually identical but a real plane for upstream to build from. - An external texture URL may now be protocol-relative (`//host/path`) as well as absolute -- recognized by the server and resolved by each viewer's own browser against its own page, so one map line can serve `http://` to a plain local server and `https://` to one that terminates TLS. `https://` remains the one to write for a texture that also needs to load in a real desktop `bzflag` client, which has no page to resolve a schemeless URL against. ## [1.2.13] - 2026-09-14 ### Added - **BZW `material`/`matref` support (issue #72).** A map may now `material` / `end` a named bundle of a texture and a tint, and pull it into a `box` or `pyramid` with `matref <name>` instead of restating `addtexture`/`diffuse` on every obstacle that wants the same look -- `addtexture`/`texture` and `noradar`/`nolighting` also work stated directly on an obstacle, with no material block at all. A stock texture name (`boxwall`, `wall`, `roof`, `pyrwall`, `telelink`, `caution`) resolves against bzo's own local asset for it; `maps/bzo.bzw` carries a labelled example next to its passability row. - **External texture URLs, loaded client-side only.** A map's `addtexture`/`texture` naming an absolute `http`/`https` URL is now read and forwarded to every client -- bzo's server never fetches one itself. Each browser decides for itself whether to load it, against an allowlist (its own origin, or `*images.bzflag.org`, upstream's own default trusted host) and the browser's own CORS enforcement on top of that; an untrusted host is never requested at all, and a trusted one that still refuses the load falls back to the obstacle's plain default texture rather than rendering blank. `maps/bzo.bzw`'s `thin_wall` names a real `images.bzflag.org` picture as a permanent demonstration -- it shows bzo's plain `boxwall` today, since that host sends no CORS header, and will start showing the real texture on its own if that ever changes. ## [1.2.12] - 2026-09-14 ### Added - **Nested `group`/`define` and teleporters in groups (issue #72).** A `define` may now hold `group` instances of other definitions, recursed the way upstream's `GroupDefinition::makeGroups` does -- a definition that names itself again while still being placed is the one case that stops rather than recursing forever, logged as "avoided recursion" the same way upstream's own guard does. A teleporter inside a `define` is now placed and named like any other member, and `link` blocks (already global and glob-matched) resolve against however many instances a definition produces, without needing one link per instance. - Three more real community maps for testing -- `ahs3_INCOMING.bzw`, `ahs3_Ironside_Battlefield.bzw`, `ahs3_XUG_FFA.bzw` -- each carrying a clear, AGPLv3-compatible license (GPL3 or CC BY 4.0) noted in its own header comment. ## [1.2.11] - 2026-09-14 ### Added - **`define`/`group` map geometry (issue #72).** A map may now `define` a template of boxes/pyramids/bases and `group` place transformed copies of it, composing scale, then rotation, then position the same order upstream does -- so a repeated structure like `bzo.bzw`'s new watchtower example no longer has to be laid out by hand. Each copy is named for the instance it came from, so placing one definition twice never collides on a name. ### Fixed - A group-derived (or any long) obstacle name no longer gets clipped by the debug label's fixed-size canvas -- the label now grows to fit the text and scales to match, reading at the same size whatever its length. ## [1.2.10] - 2026-09-13 ### Added - **Four more BZW map keywords (issue #72).** A map's `world` block may now set `flagHeight` (the clearance a superflag needs above where it spawns), `noWalls` (no world border at all -- a new `noWalls.bzw` previews it), and `freeCtfSpawns` (a colour team spawns in any of its zones every life, not only its first and the ones right after a capture). A zone's `safety <team...>` now names a landing spot for that team's flag when it is dropped somewhere unsafe, closest match first; `bzo.bzw`'s four team-spawn pads each carry their own team's now, so a flag dropped on an enemy base returns home instead of to the map centre. A map's `-admsg <text>` also repeats to everyone already playing every 15 minutes, the same way `-srvmsg` already greets a joining player. ## [1.2.9] - 2026-09-13 ### Added - **View dialog and remote map import (issue #73).** A new "View" button in Settings, open to every player, lists servers currently running on the public BZFlag server list alongside bzo's own local maps -- sortable and searchable. Picking a server imports its world over the real BZFS wire protocol; picking any map, imported or already on the server, switches to viewing it, reachable mid-game as well as before joining. The same browsing and import is also available, signed out, at `/view`. Only switching the *live* match's map (Set Map, in the Operator panel) is still operator-only. - **A map now says what it dropped.** A `.bzw` using a feature bzo doesn't read yet -- `mesh`, `arc`, `cone`, `sphere`, `group`, `material`, `physics`, and the rest -- reports how much of it loaded and how much was skipped, in chat, the moment a player actually starts viewing that map rather than during the join dialog's preview. A map's `-srvmsg` rides along the same way, now carried in the map's own data instead of pushed by the server on join -- which is also what lets it reach a Map Viewer session and not only the live match. ## [1.2.8] - 2026-09-13 ### Added - **A `zone` block's `team <n>` is read**, marking it a spawn area for one of BZFlag's team indices (0 rogue, 1-4 red/green/blue/purple). It only ever answers where a `base`-priority spawn doesn't -- rogue always, since rogue can never own a base, and a colour team's every death but its first life and its first life after a capture. `bzo.bzw`'s centre zone now carries `team 0` for rogue, and each of the four colour bases has a small `team`-zoned pad just beyond it, tinted that team's colour, so a colour tank's ordinary respawns land there instead of the map-wide random search. - `maps/eroah.bzw` and `maps/eroah2.bzw`, two more maps for the map picker. ## [1.2.7] - 2026-09-13 ### Added - **Map Viewer (issue #68).** Pick "Map Viewer" in the join dialog (or the XR menu's Player Options screen), choose a bundled or uploaded map, and look around it while the live match keeps running underneath -- visible on the scoreboard, can chat and use voice, sends the same infrequent position heartbeat an Observer does, but renders the chosen map instead of the live one. It is Observer on the wire, distinguished only by the map it asked to see; nothing about team limits, chat, or scoreboard colour changes. Selecting it and cycling its map picker previews each choice live, behind the dialog, the same way the live world already renders there. - **First- and third-person camera modes for Observer and Map Viewer alike**, a driveable phantom tank alongside the existing free-roam view. It reuses a real tank's own local movement, collision, and shot prediction as-is -- climbing over buildings rather than through them -- with effectively unlimited Wings and cosmetic-only shooting that never reaches the server or any other client. Cycle into it with `C`, the Settings panel's Camera row, or the XR menu's Camera row, the same three paths that already cycle every other roam view. - **Every map bzo serves is now hashed and cached at an immutable URL** (`/maps/<hash>.json`, brotli-compressed through the existing sidecar pipeline), so a client fetches a world once and a reconnect or a Map Viewer preview of the same map costs nothing further. World size now travels only inside that per-map file, not in `init`'s config, since it is a property of the map rather than of the server or the match being played on it. ### Fixed - **The Settings/XR Camera row's left and right now walk the roam-view list in opposite directions**, rather than both stepping forward. Entering a view in reverse lands on its last subject, symmetric with a forward entry always landing on the leader. - **`document.hidden` no longer pauses an XR session.** Entering an immersive session backgrounds the flat page on top of, not instead of, watching the game through the headset, and the page stays "hidden" for the whole session -- so treating it as a reason to pause left no way back to unpaused until the session ended. - **A fresh join clears any pause the client still believed it was under**, including the paused-sphere visual, rather than carrying one over from a life it had nothing to do with. - The XR menu's "Apply and Join" now sends the same team/map fields the flat dialog's OK does, fixing Map Viewer showing up as Rogue on the scoreboard and the chosen map being dropped when a join was confirmed from the XR side after being staged in the flat dialog. - Two live-instance-only bugs caught testing map hashing against the running match: a background hash of a bundled map could overwrite the live match's own world size, and non-deterministic cloud placement made a map's hash change on every server restart. ## [1.2.6] - 2026-09-12 ### Added - **The mouse wheel zooms the radar** when the pointer is over it, using the same fine continuous step `+`/`-` already do -- not the "Radar: Medium" button's three-preset cycle, which wraps and would make a wheel gesture jump to the opposite extreme. Answered by the same coordinate-based listener the chat scrollbar fix below introduced, since the radar sits at the top-right corner for the same reason chat sits at the bottom -- exactly where looking around puts the cursor -- and keeps `pointer-events: none` for the same reason. - **Middle Mouse drops the flag**, upstream's own default binding (`ActionBinding.cxx:95`) alongside Space, which was the only one of the two bzo had. ### Fixed - **The chat window scrolls like Debug and Help do, and the mouse wheel now reaches it while idle without stealing clicks the game still needs.** It used to render only a fixed slice of the scrollback behind a `wheel` listener bound to two separate elements, so the wheel could miss whichever panel the pointer actually was over. `#chatMessages` scrolls natively now (`overflow-y: auto`, the shared themed scrollbar included), and a viewer scrolled up reading history is no longer yanked back to the bottom by a message arriving while they read. The transcript still leaves clicks and drags to the game while chat is idle -- exactly where mouse control puts the cursor to fire while reversing -- so the wheel is answered by a small coordinate-based listener instead of by taking the pointer; a touch drag still needs chat active first, the same as a click does. PageUp/PageDown/End are unchanged. - **`nodemon.json` lists `css` and `html` in its `ext`.** Nodemon's own default (`js,mjs,cjs,json`) left a `.css`/`.html` edit unwatched even though `public/` is a watched path, which let the `precompress` brotli sidecar for a changed stylesheet go stale under a running dev server -- exactly the chat scrollbar fix above, until this was caught. ## [1.2.5] - 2026-09-12 ### Added - **`/silence <callsign>` and `/unsilence <callsign>` (issue #5)**, bzo's first client-local commands: entirely in the browser, never sent to the server, matching upstream's own client-side command layer. Filters that player's chat and action messages for you only -- nobody else's view of them changes. `/silence -` and `/unsilence -` silence or restore every unauthenticated player at once, bzo's answer to upstream's own "silence every unregistered player", since an unauthenticated name can be anything and changes on every rejoin. Extended to voice, which upstream has none of to extend: a silenced player's voice is muted too, and stays muted through a reconnect under the same callsign. - **`/highlight <pattern>` and `/cmds` (issue #5)**, completing bzo's client-local command table. `/highlight` is one case-insensitive regular expression that pulsates, underlines and colours cyan every chat line matching it -- on every redraw, so changing the pattern relights lines already on screen -- and a bare `/highlight` clears it; both persist across sessions. `/cmds` lists bzo's own local commands, then asks the server for its own list too, the same two-part answer upstream's own `/cmds` gives. ## [1.2.4] - 2026-09-12 ### Added - **Score limits (#67).** `-mps <score>`/`maxPlayerScore` and `-mts <score>`/`maxTeamScore` (`server.json` or a map's `options` block) end the match the moment a player's or a colour team's wins minus losses reaches the limit, through the same game-over hold `-time` (#66) uses -- works with or without a clock configured. A `scoreOver` broadcast names the winner, and the client shows "*name* won the game" or "The *colour* team won the game". The Operator panel (flat and XR) gets Player Score Limit and Team Score Limit sliders alongside the Time Limit one. ### Fixed - **A score limit could permanently soft-lock a clockless server.** `startMatch` (`/countdown`, the Operator panel's Start button) used to require a time limit to be configured at all; once a score-limit-only server's match ended, there was no way to start the next one. It now always resets scores and lifts the hold, and only the clock-specific parts -- the running `timeUpdate` broadcast, the "Match duration is..." announcement -- are conditional on a time limit existing. - **A score-limit win on a clockless server never showed "GAME OVER" on the flat scoreboard.** Its clock element only rendered when a numeric `timeLeft` existed; the XR scoreboard panel already keyed off `gameOver` independently, and the flat one now matches it. ## [1.2.3] - 2026-09-12 ### Added - **Timed matches (#66).** `-time <seconds>`/`timeLimit` and `-timemanual`/`timeManualStart` (`server.json` or a map's `options` block) run a server-side match clock with a game-over hold at zero -- no respawn, no fresh spawn on join -- until the next `/countdown`. `/countdown [pause|resume]` and `/gameover` are the chat commands; the Operator panel (flat and XR) gets the same four actions as buttons, plus a Time Limit slider (0 is no limit) and a Manual Start checkbox, live with no restart. The clock rides the scoreboard model both surfaces already share, and "Time Expired - GAME OVER" reaches a headset as a toast for free through the existing alert system. ### Fixed - **A dialog no longer hands a touch device its keyboard by default.** Opening the Operator panel (or any other dialog) auto-focused its first text field, which pops a phone's on-screen keyboard over the panel that was just asked to open. It focuses the close button instead on a touch device; a physical keyboard's tab order is unchanged. - **Virtual controls no longer sit on top of an open dialog.** The on-screen joystick's `z-index: 9999` put it above every menu, so a phone with it enabled got an overlay in front of Settings, the Operator panel, or the entry dialog, and the dialog underneath never received its touches. It now hides for any menu context and resumes exactly the state it was in before, with nothing to restore. ## [1.2.2] - 2026-09-12 ### Fixed - **A tank could get stuck on a teleporter's frame**: permanently, jumping into its jamb; passing straight through, driving square into its front or rear face; or hanging entirely, flying with Wings into it while gaining height. All three came from the same collision code taking shortcuts upstream doesn't: a static end-of-step read instead of a swept corner ray (`Obstacle::getHitNormal`, `Obstacle.cxx:123`) to decide which face was hit, a "does it overlap the doorway" test instead of testing each jamb pillar directly (`Teleporter::inBox`, `Teleporter.cxx:259`), and a header ceiling check that didn't verify the tank was actually over the open doorway rather than still against a pillar. See `docs/teleporter-collision-fix.md` for the full breakdown. - A player stuck in place (a livelock, or any other cause) with a jump still in progress stopped sending position updates entirely once the jump ran past the couple of seconds an ordinary one takes, since the heartbeat that covers a motionless grounded tank used to exempt an airborne one. Sent every `MAX_UPDATE_INTERVAL` regardless of ground/air state now. ### Added - **Server-side movement validation extrapolates on the client's own clock** rather than the server's measured arrival gap, closing a jitter window where a legitimately laggy connection could be flagged for exceeding an interval it never actually claimed to move within. See `docs/lag-plan.md`, "Extrapolate on the client's clock, not ours". - **`/api/players`**, a loopback-only diagnostic endpoint listing every connected player's position, speed, flag, and vitals in one request, for testing without typing `/mv`/`/lagstats` per player or grepping `server.log`. - `maps/bzo.bzw` gains a permanent test fixture (`frame_pillar_a`, `frame_pillar_b`, `frame_header`) -- three plain boxes sized to a default teleporter's outer footprint, for testing corner-slide collision independent of teleporter-specific code, including in unmodified `bzfs`/`bzflag` for direct comparison against upstream. ## [1.2.1] - 2026-09-11 ### Changed - **A box's side walls stop restarting their texture at each box's own corner.** Upstream's AntiFlicker (`remapTexCoords`, `QuadWallSceneNode.cxx:65`) recomputes wall UVs from world position instead of the wall's own local span, so two boxes that touch -- stacked to build a taller wall, or simply adjacent -- sample the same phase of the tiling instead of each starting fresh. Baked in at map load with no per-frame cost and no menu option: it only ever helps, so there is nothing to switch off. Bases keep their own stretched-to-fit tiling. ## [1.2.0] - 2026-09-11 ### Added - **A teleporter proximity flash.** The view washes yellow as a tank nears a teleporter's opening, graded by distance the way upstream's `SceneRenderer::renderDimming` blends it (`Teleporter::getProximity`, `World::getProximity`, ported into `getWorldTeleporterProximity`). It is a mesh parented to the camera rather than a 2D screen overlay, so it renders correctly per eye in a headset. - **`maps/bzo.bzw` plays team mode**, with the four coloured bases as capture targets and BZFlag's usual per-team player limits, and its flags are single use: `-set _maxFlagGrabs 1` means a good or bad flag disappears once taken rather than drifting back onto a zone it does not belong on, and a bad flag now sheds in 30 seconds or one kill (was 50 and two) with an antidote flag in the world the whole time. - **`_maxBumpHeight` is a server and map option.** Upstream's bump-climb threshold (`LocalPlayer.cxx:534`) was a hardcoded constant; a server.json `maxBumpHeight` or a map's `-set _maxBumpHeight` now rides `GAME_CONFIG` to the client the same way `_wingsJumpCount` already does. ### Changed - **Static asset requests are rate limited.** CodeQL flagged `js/missing-rate-limiting`: every static handler answers a request with a filesystem read and nothing stood between an unauthenticated request and repeated disk reads. Sized for a LAN party sharing one address, the same reasoning as the existing login limiter. ### Fixed - **A tank that jumps up into a floating box's underside keeps its speed.** `getTankHitNormal` had no bottom-face case for a box, unlike its pyramid branch and unlike `getShotObstacleNormal`'s own box case just above it, so the hit computed a side-wall normal instead of a ceiling one and redirected horizontal speed instead of leaving it alone. Fixed in both halves of the collision pair, matching what real bzflag does: vertical speed stops, linear speed carries through, and the tank drops from where it hit. - **`maps/flagbuffet.bzw` is removed.** `maps/bzo.bzw` now carries everything it tested and more, so every doc and code-comment citation of the old map points at the new one, including a full rewrite of `AGENTS.md`'s flag-testing workflow against `bzo.bzw`'s actual coordinates. ## [1.1.0] - 2026-09-11 ### Added - **`maps/bzo.bzw` is the new default map**, in place of `test.bzw`. `ID` sits alone at the centre; a ring of every bad flag surrounds it, clustered by what they take from a tank -- steering, momentum, senses, the tank itself -- one theme per compass edge, with `WA` kept in the sight group even though bzo never spawns it, so the same file plays unchanged on an unmodified bzfs. A ring of every good flag surrounds that, alphabetical clockwise from north as `flagbuffet.bzw` already orders them. A base sits on each compass point, coloured as `hix.bzw` does. The four corners each carry one obstacle test: the octagon and a thin wall in the north-east, the pyramid/box overlap row and the nested-pyramid stack in the north-west, the step and bump-height tests plus the passability-keyword row in the south-east, and the teleporter pair plus two jump-into-overhang tests in the south-west. `example-server.json` now names it. - **`maps/bzo.bzw` sets its own gameplay defaults** -- `-j` (jumping), `-ms 5` (five shots in the air at once) and `-set _wingsJumpCount 4` -- in its own `options` block, so the world plays the same regardless of what a server's own config says, the same way `hix.bzw` already does. ### Changed - **The server reads `now` once per handler entry**, the other half of `docs/lag-plan.md`'s cached clock (the client's own half shipped in v1.0.100). `validateMovement`, `getShotRejection`, the `Projectile` constructor, `grabFlag`, `dropFlag`/`getFlagDropPosition` and `checkAntidote` all take `now` as a parameter now rather than reading the clock again partway through one message, which is what let an extrapolation and the drift check reading it disagree by however long the intervening code took to run. - **bzo implements upstream's best-looking variant of an effect by default, not its cheapest one.** `AGENTS.md`'s client-display-options policy said the opposite; a look most players turn on for themselves is the one worth shipping here from the start, and a cheaper fallback is now something a measurement earns rather than the default assumption. - **The landing effect matches upstream's `StdLandEffect`.** What bzo drew was a flat ring that only grew wider; upstream's is a shell that flares wider *and* rises for its whole one-second life, textured with `dusty_flare` -- the same speckled dust texture the shot-teleport collar already wears -- rather than a smooth flat colour. ### Fixed - **A shot that does not carry Ricochet still bounces where the server says it does.** `ricochet` in a `.bzw` forces a bounce off that one obstacle regardless of what the shot itself carries, and the server already handled it correctly, but the client only ever traced a shot's path locally when the shot's own `ricochet` flag was set -- so an ordinary shot flew straight through on screen while the server reflected it, and the explosion landed in the right place with nothing shown getting it there. - **A tank standing on a low platform can climb the next low ledge.** `resolveTankStep`'s `onGround` was `playerY <= groundLimit`, which is only ever true on the world floor itself -- so a tank could bump up from the ground onto a step, but never from that step onto the next one. It now also asks the tank's own `onGround`/`onObstacle` state, which upstream's equivalent gate (`oldLocation != InAir`, `LocalPlayer.cxx:532`) already covers by being sticky rather than recomputed from height. - **Climbing a ledge no longer buzzes.** The bump-climb path set the tank's new height but never marked it as resting on the obstacle, so the next frame found it above the world floor and not on a building, called that falling, and landed it again an instant later -- one thump and one ring per frame for as long as it held still on the ledge. The bump now sets the same `onBuilding` state a landing does. - **A coloured flag-marker box is drivable and shootable on real bzfs.** `color` on a `box` is a material property there, and a box that has one is built as a full mesh with every face solid by default instead of upstream's faster, uncustomized path -- so the bad-flag pads in `maps/bzo.bzw` and `maps/flagbuffet.bzw` stood solid on a real server despite bzo's own zero-height pads never needing `passable` stated. Both maps now say `passable` on those boxes and give them a real (if tiny) height, which also clears the `invalid mesh face` warning bzfs printed for every one of them -- a zero-height box's side faces are degenerate triangles once it is built as a mesh at all. ## [1.0.101] - 2026-09-11 ### Added - **Signed-in players can log out.** The login row now sends a signed-in player to `/logout`, which removes their stored session and clears the cookie before returning to `/`, the same page-navigation shape `/login` already uses. ## [1.0.100] - 2026-09-11 ### Added - **Lag, jitter and loss are measured for every connection, and `/lagstats` reports them.** The round trip comes off the WebSocket keep-alive ping that was already running, and jitter off the interval each move already carries -- so neither costs a new message. The reply is bzfs's own: one row per player, worst connection last, an operator also seeing slot numbers. A connection whose first ping has not come back reads as `--` rather than as a perfect zero. The ping now goes out every ten seconds, as upstream's does, because that is a measurement cadence rather than a liveness one. ### Changed - **The client reads the wall clock once a frame.** Everything measured against a timestamp the server also holds now shares one reading per frame, so a whole frame agrees about when it happened -- shot ageing in particular used to take two readings per shot per frame and difference samples taken microseconds apart. Motion and send timing are unchanged: those run on a monotonic clock and always did. ## [1.0.99] - 2026-09-11 ### Added - **A sky beacon over everything the radar rings.** A coloured wedge hanging out of the cloud layer down to a point just above the player's own team flags, the antidote and the rabbit -- the team's tank colour, the antidote's yellow, and the rabbit's hunt cyan, so a ring on the panel and a wedge in the sky are visibly the same mark. A ring answers "where is it on the map" and leaves the player to turn a top-down panel into a direction to drive; a mark standing in the world has already done that. This is what an immersive session has instead of upstream's heading tape, and it is drawn in a flat client for the same reason the rings are: the tape is somewhere to look other than the world. Because the beacon hangs from the clouds and ends above its target, its length is the target's altitude -- a rabbit mid-jump flies a shorter one -- and it fades out within about 45 units, where the thing itself is in view. Closes #61. ## [1.0.98] - 2026-09-11 ### Added - **`listen` says where the server answers, host and port together.** The way a proxy's own config writes it -- `[::]:3000`, `127.0.0.1:3000`, `[::1]:3000`, `:3000`, or a bare host -- because a host and a port are one decision. `port` still works and fills in whenever `listen` names no port, and both read the environment first (`LISTEN`, `PORT`) and `server.json` second. The default is unchanged: every interface in both families on 3000. Naming a loopback is how an operator behind a reverse proxy stops anyone reaching the port directly, where there is no certificate and a browser therefore offers neither brotli nor a service worker. `localhost` resolves to `127.0.0.1` and says so, since a socket binds one family and the name promises two. ### Changed - **Superflags are off until a server or a map asks for them**, as upstream has them (`numExtraFlags(0)`). A config that never mentions `superFlags` now carries none, so a map written without flags is played without them. A `superFlags` block naming no usable count is upstream's bare `-s`, which still means sixteen, and a map's own `-s`/`+s` still replaces whatever the config said. ## [1.0.97] - 2026-09-10 ### Added - **Assets are compressed before they are asked for.** Every text asset -- the models, the client code, the markup, Three's two builds -- now has a brotli sidecar under `cache/br/`, served to any client that asks for `br`. A cold join measured at the wire falls from 9,958KB to 5,575KB. The models are most of it, 1,911KB to 228KB: an OBJ is ASCII numbers and compresses about ten to one, and nothing was compressing them, because express announces `.obj` as `application/x-tgif` and a proxy's mime list never matches that. Quality 11, which is three seconds for a megabyte and so is done once at boot rather than per request -- off the event loop, after the port is open, and never blocking a game the sidecars are not needed to play. A sidecar is named for the digest of the file it was made from, so one that exists is the current file's and one left by an earlier version is a name nothing looks up; the same pass deletes those. Where a sidecar is missing, or `cache/` cannot be written at all, the request is answered with the file itself. `npm run precompress` builds them without a server, which is what the container image does. ### Fixed - **The login rate limiter stops naming an option its library does not have.** `ipv6Subnet` belongs to express-rate-limit 8, and bzo asks for 7, which answered every boot with an `ERR_ERL_UNKNOWN_VALIDATION` on stderr -- loud on a fresh checkout and invisible here, since `server.log` carries what bzo logs and not what a dependency prints. Nothing was disabled by it and nothing behaved differently: the key generator already buckets by the exact address it reads, which is what the option would have been asking for. - **A model is served as a model.** express's mime table maps `.obj` to TGIF's format and `.mtl` to raw bytes; both are now their own registered types, `model/obj` and `model/mtl`. Wrong on its face, and it also hid the largest text the game serves from anything that compresses by type -- no proxy's list holds `application/x-tgif`. The loader asks for text either way and never consulted the type. - **Sounds are fetched the way every other asset is, so the cache can keep them.** The service worker decides how fresh a sound has to be: `/audio/` is served cache-first out of a cache keyed to the build, and a miss there revalidates. Asking for the file with `no-store` on top of that kept it out of the HTTP cache as well, which left the revalidation nothing to validate against -- so the first load the worker actually controlled re-downloaded all 2.3MB of sound in full, and every change to the client code replayed it. Measured at the wire, that load now costs twenty-eight conditional requests and no bodies instead of 2.3MB. ## [1.0.96] - 2026-09-10 ### Added - **A map may paint a box or a pyramid.** `color` on either -- and `diffuse`, which is the same property under the name bzflag itself writes -- is read as three or four numbers between 0 and 1, upstream's own numeric colour, and the obstacle wears it over the texture it already had. A face selector in front of the keyword narrows which faces take it: bzo draws a box as its four walls and its two caps, so upstream's `x+`, `z-`, `top`, `sides` and the rest land on one of those two. The colour reaches every client with the rest of the world, and a tinted obstacle's debug label wears it too, as a base's label now wears its team's. `flagbuffet.bzw` paints the pad under each of the thirteen bad flags' zones, so a pad reads as the kind of flag standing on it. The figure is written into the map rather than looked up from bzo's own bad-flag orange: what a map is painted is the map's decision, and a client that changed its mind about that orange would otherwise silently repaint somebody's world. Everything that draws the obstacle follows the colour: its debug label wears it, as a base's label now wears its team's, and the radar shades its footprint with it, towards the panel's neutral grey by the same fraction a base's team colour is shaded, so a painted surface still reads as ground rather than as a tank. ### Changed - **A debug label wears the colour of what it names.** A flag standing in the world takes the colour of its own cloth, `getKnownFlagColor` -- a team's colour for a team flag, bzo's orange for a bad flag this client has identified, white for every other superflag -- and a base takes its team's, where every label was white before. One source for each colour, so a label cannot disagree with the thing it labels. It costs one draw call for all the tinted boxes on a map and one for all the tinted pyramids. The colour rides on the vertices, which is what lets obstacles painted differently still merge into one mesh -- the same trick that already lets every base share one material whatever team holds it -- so a map that paints nothing pays nothing. Upstream reads the property on a plain `box` as well, but turns that box into a mesh obstacle to carry it; the shape is the same either way, so bzo keeps its own box. Alpha is read and dropped, since an obstacle bzo draws is opaque, and upstream's X11 colour names are not read. See `docs/bzw.md`. ## [1.0.95] - 2026-09-10 ### Changed - **Faces buried inside other obstacles are no longer built.** Upstream already leaves out the two every map hits -- a box's bottom polygon when it sits on the ground (`BoxSceneNodeGenerator.cxx:66`) and a pyramid's base unless it is raised or stood on its point (`PyramidSceneNodeGenerator.cxx:109`) -- and this asks the same question of the whole world: a triangle every point of which lies inside the solid part of other obstacles cannot be seen from anywhere outside them, so the world mesh never carries it. The unit is the triangle and it is kept or dropped whole, so nothing is clipped and no vertex is invented. What it is really for is the way maps fake a curve. With no curved obstacle to draw, a map crosses several boxes at one spot -- `hix.bzw` builds four octagons, its top of the world and its roof out of four planks each -- and every long face of every plank is buried in its neighbours. Those are also the faces a phased tank used to see as slabs across its view, because from inside one plank the others are still solid, so the eighth dimension inside one of these reads as the shape it is rather than as a box full of walls. `hix.bzw` loses 112 of its 992 obstacle triangles, 11%; `flagbuffet.bzw`, which now carries an octagon and a thin wall out on the north-east ground for testing, loses 16 of 518. Two faces in the same plane are left alone: they hide each other equally, something has to be drawn there, and a rule that let each subtract the other would leave a hole. The tolerance is a millimetre rather than a float epsilon, because the coordinates tested are float32 out of a BufferGeometry and a map is 800 units across. ## [1.0.94] - 2026-09-10 ### Changed - **Tank motion is one pass, as upstream's is.** `doUpdateMotion` builds a velocity and hands the whole step to a single search that resolves position, height and heading together (`LocalPlayer.cxx:498`). bzo ran it twice over: horizontally through `resolveTankMotion` with `velocityY: 0`, and vertically through a support surface of its own with its own tolerances. Every disagreement between the two passes was a bug, and they were all the same bug -- one question with two answers, one from upstream and one a fudge beside it: a slope that held a tank at one threshold and dropped it at another, a landing declared by one pass and undone by the other on the same frame, a tank "on the ground" by one number and above it by another. The gap between each pair is where the tank flickered, once per frame, which is a sound and an effect per frame. The vertical velocity and the turn rate now go into the one resolver and its answer is the frame's outcome. `getTankHitNormal` in the `collision` pair is upstream's `Obstacle::getHitNormal` and `PyramidBuilding::getHitNormal`, and the sign of its `y` is the whole of what decides a landing -- upstream's `newPos[2] > 0 && normal[2] > 0.001`. That deleted, rather than added: - `findSupportSurface` and the support-surface model entirely, with `SUPPORT_SNAP_DOWN`, `ONTOP_TOLERANCE`, `isWithinSupportFootprint`, `getPyramidSurfaceContact`, `getBoxSurfaceContact` and `getPyramidTankSupportY` - `validateMove`'s 430 lines, `resolveMotionSlide`, `tryStepUp`, `tryTopSurfaceTransition`, the corner-escape counter and `resolveTankRotation` - the `startedFalling` flag, the `-0.1` vertical-velocity sentinel that stood in for a real fall, and the second `validateMove` call that re-ran the step with air physics - `hitObstacleBottom`, which reversed a rising tank's velocity at 50% energy when it met a ceiling -- a bounce with no upstream counterpart. In its place is a far smaller deviation, and the only one left in this loop: **a surface above stops a rise.** Upstream cancels only *downward* motion against a surface (`LocalPlayer.cxx:634`), so a tank that jumps into an overhang keeps its upward velocity and hangs under it until gravity turns it around -- up to `jumpVelocity / gravity`, near two seconds at bzo's defaults. bzo zeroes the rise, so the tank falls from where it hit. Only the vertical: against a flat ceiling upstream's `mag` is zero and leaves the drive alone, and so does this, so jumping into a ceiling while moving keeps your speed and simply starts you falling. `npm run test:motion` holds it. `checkCollision` is `World::hitBuilding` again and nothing more: the first solid the tank's box is inside of. It no longer reports which surface was met or whether the tank is on top of one, because the resolver asks `getTankHitNormal` that once about the step instead of once per obstacle here. Its vertical slack is gone too, which was 0.15: upstream's `inBox` has none, and a band let a tank on an inverted pyramid's plateau sink 15cm before the solid noticed -- by then it was inside the wedge, where every height is solid and the search has no clear point to stop at, and the tank hung there. **The server asks the same question through the same code.** `findTankObstacle` in the `collision` pair is `World::hitBuilding`, and `checkCollision` on both ends is now a wrapper over it -- the client saying which world to look at and what the local tank is, the server adding its logging and its tolerances. There were two copies of that loop and they had drifted twice: the server tested every inverted pyramid as though it were upright, and its vertical gate carried a slack the client's did not. `getShotTeleporterDims` was a byte-identical duplicate on both sides and moved into the pair with it. Both came off the "known duplication" list in AGENTS.md. The server's tolerances survive as *parameters* of that one loop rather than as a second copy of it: `slack` shrinks the occupant, `verticalEpsilon` widens the height gate, and both may only ever remove a collision, because the server has to stay strictly more permissive than the client about a quantized position. The drift thresholds and the extrapolation stay where they are -- the server extrapolates every tank between packets because it is the server that decides a kill, which is bzfs's own arrangement, not a duplicate of the client's resolver. What that extrapolation lacks is a clock it can trust: bzo sends no timestamp with a move, so network jitter lands inside the number the anticheat judges. `docs/lag-plan.md` records what upstream measures and how, and stages the work; nothing of it is built. `checkCollision` answers with the obstacle or null now rather than wrapping it in a result object and naming a `type`, because the resolver is its only caller and only ever wanted the obstacle; its `ignoredObstacles` parameter went with the pass that used it. Nine constants and helpers across the `collision` and `motion` pairs lost an `export` nothing outside those modules reads. The debug marker and outline for the surface a tank is on are read off the obstacle and the hit normal now rather than off a support record -- a flat top is the obstacle's own top rectangle, a slope the triangle from the apex down the face the normal names -- so both survived the support model they used to come from. The marker stands at the middle of what the outline traced, rather than on the obstacle's own centre and top: on a pyramid that was the apex, however far down a face the tank actually was. Net 837 lines out of `public/client.js`, 626 across the tree. Fewer collision tests per frame, too: the old path ran `checkCollision` several times *and* swept every collider in `findSupportSurface`, every frame. Upstream's own loop shape matters and is easy to get wrong: on meeting a surface it zeroes the vertical velocity and **keeps going** with the time the step has left, so the next pass carries the tank along the surface it just met. Ending the step there instead makes driving on any roof feel stuck, since a tank on a surface always carries a little downward velocity and every frame hits. ### Fixed - **A tank falling onto a pyramid stops on it instead of ending up inside it** (#49). Landing was gated on the face's normal being within 0.7 of vertical, a threshold bzo invented: nothing steeper held a tank up, so the resolver kept x and z where they were while gravity carried on lowering y, and a tank dropped onto `flagbuffet`'s pyramid arrived at ground level in the middle of it. BZFlag has no such threshold -- `doUpdateMotion` reads a landing off any hit normal above 0.001 (`LocalPlayer.cxx:617`) and a pyramid's always tilts upward -- so every face now holds a tank, and where it holds it is `getPyramidTankSupportY` in the `collision` pair: the lowest height at which the tank's own box clears the shrunk cross-section. A tank therefore rests on the corner nearest the pyramid's axis rather than on the slope under its centre, and one whose box covers the axis balances on the point, both as upstream does. Three more things followed from that, each of which had to be right before a tank could get off a slope again: - **No slope may be driven up, however gentle.** Upstream bumps a tank up a ledge only where the obstacle `isFlatTop()`, which for a pyramid means `flipz`; the base footprint of an upright one is fully solid at ground level, so a tank stops at its edge. The shortcut that lets a tank standing on a roof ignore that building's walls no longer fires for an upright pyramid either -- there the wall *is* the surface holding the tank up, and dropping the hit walked the tank up the slope a step at a time. - **A downward step a slope refuses stops at the surface it met**, rather than holding x and z while y keeps falling. - **A tank the code calls "on the ground" is on the ground.** `onGround` was decided over a 0.1 band while the snap to the floor only fired at or below it, so a tank nine millimetres up was both: it landed -- the sound and the ground ring -- and `validateMove` started it falling again, on the same frame, for as many frames as the creep down took. That is the buzz at the foot of a slope, measured at thirty landing effects for one descent. Upstream has one test and no band: `location` becomes InAir above the floor and nowhere else (`LocalPlayer.cxx:670`), and the clamp inside its move loop puts a tank that has reached the floor exactly on it. - **A turn that would end inside a building does not happen.** Upstream's collision search runs over the azimuth as well as the position (`LocalPlayer.cxx:565`) and `newAngVel = 0` is what is left of a turn that hit something; bzo settled the heading outside the resolver with no test at all. A tank parked beside a pyramid could turn its own corner into the slope -- the corner nearest the axis swings inward, where the surface is higher -- and be carried up the face and dropped again on the way back out, landing each time. This also replaces the expulsion that had been added for it, so nothing lifts a tank out of a slope any more: nothing puts it there. - **Descending a slope is one landing, not one per frame.** `validateMove` calls a tank fallen as soon as it is more than `ONTOP_TOLERANCE` above the surface under it, while `findSupportSurface` took a surface back up to the wider `SUPPORT_SNAP_DOWN` below -- so a tank whose slope dropped between those two numbers in one frame fell out of the surface and was grabbed straight back onto it, landing again, every frame. That is the buzz at the base of the pyramid: a landing sound and a ground ring per frame, and a tank that crawls because it spends every other frame being re-seated. Both numbers answer one question, so a falling tank is now held only within `ONTOP_TOLERANCE`, and the wider snap stays what it was for -- a tank still driving along a surface, following it over a step. Which frame rates showed it depended on `speed x dt x slope` landing between the two. - **Driving off a surface carries the speed the tank is being driven at**, not the speed measured from the previous frame's displacement. A slope drops away faster than the support snap follows, so every frame downhill is a fall, and a measured speed reads zero for a tank that has only just been asked to move -- which pinned a tank to the slope it landed on. This is the same class of fault as the ledge in #39 and now takes the value the jump path already used. ### Added - **`/flag drop [player]`**, which upstream has no equivalent of and is a deliberate deviation. Its `FlagCommand` takes `<reset|up|show>` (`commands.cxx:593`) and can only send *every* superflag in the world away, which on a map built for testing flags empties the zone you are standing on. bzo is developed by driving it, and the move that costs a test run is being handed the wrong flag -- so this is the other half of `/mv`: put the tank on the zone you want, drop what it is carrying, take the one that is there. No target means your own. A sticky flag is zapped rather than dropped, exactly as dying with it does, so it cannot be used to plant a bad flag on somebody else. - **`--window` on `scripts/headless-client.mjs`.** SwiftShader costs per pixel, so the window size is the frame rate: the default 1280x800 probe runs at about 8fps, which moves a tank two feet per step and walks straight over anything that only happens at a player's frame rate -- the landing buzz above among them. `--window 320,240` runs near 60fps and reaches it. - **A row of five pyramids on `maps/flagbuffet.bzw`**, from nearly flat to nearly vertical over one shared 40-unit base, plus a `flipz` one for the flat top. They sit on the empty ground south of the flag rings so what a slope does to a tank can be driven rather than argued about. ## [1.0.93] - 2026-09-09 ### Added - **A fifth tank: `Bzship`**, contributed by Aeonovyli under CC BY-SA 4.0 and re-tagged for bzo. The mesh is theirs untouched; its 37 Blender-default objects were merged into the `body`/`turret`/`barrel`/`ltread`/`rtread` names `docs/tank-model-format.md` requires, and it was then stretched per axis to fill the 2.8 x 2.05 x 6.0 tank hit box. **It is not sized correctly and may not stay.** The hull is wide and flat where the box is narrow and tall, so filling every dimension distorts it rather than framing it; it carries no UVs, so every surface samples a single texel and only the team tint reads; and at 26,380 triangles across 37 source objects it is twenty times the shipped tanks. ### Changed - **A tank comes from its OBJ file or it does not exist.** The renderer's procedural stand-in tank is gone, and with it the way a model that could not be built reported itself as working: picking one used to hand the player a generic tank built from boxes and cylinders, which is not a fault anybody reads as a broken asset. A model that does not name `body`, `turret` and `barrel`, or that names no treads or wheels on both sides, is no longer offered at all -- the server holds each file in `public/obj/` to the contract in `docs/tank-model-format.md` as it lists the models, and logs the roles a rejected file is missing. `public/tank-parts.mjs` and `server/tank-parts.cjs` are the mirrored pair both ends read it from, and `npm run test:tank-models` holds every shipped model to it. ## [1.0.92] - 2026-09-09 ### Security - **`js-yaml` is lifted to 4.3.2**, clearing GHSA-2883-xcg3-v3hh: `maxTotalMergeKeys` counts the keys a merge yields rather than the merges themselves, so a document of empty merge sources costs the CPU the limit was meant to cap. It reaches bzo through `eslint` -> `@eslint/eslintrc`, which is a devDependency and in no shipped image, but `npm audit` gates the release workflow. - **`/login` is rate limited**, at ten requests a minute per address. It is the one public route that spends something -- a callback carrying a token makes bzo ask my.bzflag.org about it -- so an unmetered endpoint is an amplifier pointed at bzflag.org as much as at bzo. The bucket is keyed on the address the proxy names rather than on Express's `req.ip`, which behind the proxy the README describes is the proxy itself for every player at once: one bucket for the whole internet would be a self-inflicted outage rather than a limit. A refusal is a plain-text 429 and a log line naming the address (`js/missing-rate-limiting`). - **The login callback stops echoing the token it was given.** A `t` parameter with no callsign in it came back inside the 400, which is markup in a response of bzo's own making whatever the content type says. The reply is a fixed sentence now; the value it was sent is still in the log, where anybody diagnosing a real callback was already looking (`js/reflected-xss`). - **The headless probe types the player name instead of building it into code.** `--name` was escaped with `JSON.stringify` and interpolated into an expression sent over CDP -- an escape that only mostly works, and the wrong tool for turning data into code. `Input.insertText` carries the name as a parameter, and types it the way a player does, input events and all (`js/bad-code-sanitization`). - **The headless probe validates a CDP reply before dispatching on it.** The callback for a reply was looked up by the id the reply carried and called on the spot. It is now taken out of the pending map before it runs and run only when it is a callback the script put there itself, so a reply naming an id nobody waits on is dropped rather than dispatched (`js/unvalidated-dynamic-method-call`). ## [1.0.91] - 2026-09-09 ### Added - **The operator panel offers the game's shape.** Teams on or off, rabbit chase off/score/killer/random, jumping, a playing limit and a limit for each of the six teams, staged with everything else and committed by the one confirm -- which reads *Restart* for all of them, since bzo resolves the team layout and the flag pool once at boot. Every row is a stepper or a toggle, so the whole panel is still reachable with left, right and select in a headset. A row the mode cannot honour greys out as it is staged: rabbit chase greys the Teams row and the four colour team rows and relabels Rogue as Hunter, which is the limit the hunters actually get (`CmdLineOptions.cxx:1596`), and turning teams off greys the colour rows on its own. A team limited to zero is off, as it is upstream and as a map's `-mp 10,0,4,0,2,8` already read. - **The playing limit is enforced the way bzfs enforces it.** `maxPlayers` counts the tanks and excludes the observers -- upstream's `maxRealPlayers` -- and every playing team's own limit is clamped down to it. Reaching it turns an arrival into a spectator rather than refusing it; reaching the total, which is the playing limit plus the observer limit, refuses the arrival with "This game is full". Until now the number only supplied the default per-team limit and capped nothing. - **Self destruct counts down.** `Q` starts upstream's five second countdown -- `cmdDestruct` (`clientCommands.cxx:400`) -- with the count on the HUD and `Q` again to call it off, where before it blew the tank up on the keypress. The count is the client's own, since the server owns the pause countdown only because it decides whether a tank may be hit and has no such stake in this one; what the client sends when the count runs out is the same self destruct request as before. A tank that dies under the countdown does not come back to it. ### Fixed - **A tank shot during the pause countdown comes back playing.** The server abandons a countdown the moment the life it was asked in ends, and told nobody: the client kept counting a countdown that would never finish, and since a countdown is one of the two things that freeze the tank, the controls never came back -- leaving a menu sent a pause toggle that the server either dropped, for a tank still dead, or read as a fresh pause request. The client now drops its copy off the same fact upstream drops its own on (`playing.cxx:6866`): a countdown on a tank that is not alive is over. The new life then asks again for whatever is still true -- a menu still in front of the game, or the countdown the player was in when they died -- and `P` calls it off. The same wedge could be reached without a countdown at all, since genocide and `/kill` both reach a paused tank. The rules over all of this now live in one place, `public/pause.mjs`, with tests: the pause key and a menu covering the game read the same state machine, and it was them having a rule each that let a life ending fall between them. - **Every obstacle carries a `rotation`.** The BZW importer left the field off any `box` or `pyramid` whose block gave no `rotation` line, so each reader carried a default of its own -- `obs.rotation || 0` in the collision pair, the renderer, the radar and the logs -- and the one place that formatted it instead of doing arithmetic with it threw: an anti-cheat collision report against an unrotated box died on `Cannot read properties of undefined`, the throw surfaced through the message handler's catch as one unattributed line, and the cheat that provoked it went unrecorded. The importer now states 0 for a block that says nothing, and every reader reads the field. - **The operator panel's sliders answer the arrow keys and a controller.** Left and right on a focused row are the dialog model's to spend, so the shot limit slider was reachable with a mouse and dead to everything else; every row of the panel now takes them -- steppers step, toggles set, and a `<select>` cycles for a controller that the browser's own arrow handling never sees. Cancel also paints the rows back to the server's values instead of leaving the abandoned edit on a hidden panel. ## [1.0.90] - 2026-09-09 ### Added - **`?follow=leader` is a link to hand somebody who wants to watch.** It joins as an observer in the follow view on whoever is leading, without stopping to ask for a name -- a browser that has one keeps it, one that does not joins under the name the server gives it. Nothing is saved and `C` leaves the view, so a reload of `/` is an ordinary player again. The value names who to watch, which leaves room for a callsign later; a value bzo cannot resolve joins normally rather than watching the wrong tank. It is on `public/test.html` with the URL knobs. ### Fixed - **An observer's radar follows the view it is actually looking through.** The virtual tank every observer carries -- the one the radar, the heading tape and the position other clients place a voice at all read -- was moved to the roaming camera each frame, which is the eye only in free roam. Following or driving with a tank left it parked wherever free roam last put it, so the radar stayed behind while the view crossed the map. It now takes the eye of whichever view is running and upstream's `roamViewAngle` with it. An observer that has drifted more than a quarter of the nearby voice radius from the position it last sent also updates early, at most once a second, rather than waiting out the rest of its five second heartbeat. ## [1.0.89] - 2026-09-09 ### Added - **Tanks leave tracks.** A pair of tread marks every twentieth of a second behind a tank driving forward and in front of one reversing, fading out over three seconds -- `TrackMarks.cxx` and the `Player::updateTrackMarks` that feeds it, at upstream's own size, spacing and fade. They are laid on roofs as well as on the floor, and a tread hanging over the edge of a roof leaves nothing, which is upstream's `InitAirCull`. `N` Narrow leaves no marks and neither does a burrowed tank, both of which are refusals `addMark` already makes. Of upstream's three kinds of track mark this is the one bzo can draw: the puddles need a reflective ground bzo does not have, and nothing in BZFlag ever asks for the third. The two Effects-menu knobs, `userTrackFade` and `trackMarkCulling`, are absent as usual -- and half of the second one is a per-frame re-cull for marks a physics driver has carried away, which bzo has nothing to carry them with. The whole world's marks are one mesh and one draw. A mark is written into a fixed slot when it is laid and never moved again, so the only per-frame work is the fade, and the buffer upload covers the marks that are alive rather than the size of the pool. The pool holds 512 marks -- eight tanks driving without pause -- and a full one drops its oldest, so a crowded map shortens every trail instead of costing the client anything. Closes #24. ## [1.0.88] - 2026-09-08 ### Added - **The flags you are hunting for are ringed on the radar.** The ring the rabbit wears now marks your own team's flags and the antidote as well, and follows any of the three out to the border of the panel when it is past radar range, where an ordinary flag is simply dropped. Upstream rings none of them: it leaves the two flag bearings to the heading tape (`prepareTheHUD`, `playing.cxx:6820`), which an immersive session has no room for. The radar canvas is the surface to put them on because it costs nothing -- it is uploaded every frame of a session whatever is drawn on it, and the XR radar panel is textured from it. A flag stays a cross out at the border where a tank degrades to a dot: a tank's blip is an arrow because it carries a heading, and a heading is the one thing a pinned marker no longer has, while a cross carries nothing but a position. A flag on a tank takes `drawFlagOnTank`'s larger cross over its carrier's blip, so a single marker answers who has it and which way they went. The ring takes the colour of the cross it rings rather than the rabbit's hunt cyan, which the rabbit needs only because its own blip is grey. ### Changed - **A flag is the same colour on the radar as it is in the world.** The panel asked the flag table what the wire had just said, so a flag this client knew to be bad -- in its hands or lying on the ground -- still drew white there while the world drew it orange. Both now read the flag record, so the one warning colour reaches every surface. A team flag keeps upstream's lift for the dark panel, which is its own split between `getColor` and `getRadarColor`. - **Identify asks rather than being told.** Upstream's `searchFlag` sweeps for every player on every position update and pushes the answer whether or not the client could already give it, which along a row of flags is a packet per flag per pass. bzo's client makes the same sweep -- `findNearestGroundFlag` in the flags pair, so neither end can pick a different flag -- names the nearest one itself when it recognises it, and asks only about one it cannot name. The server answers by sweeping its own copy of that tank's position, so the request carries nothing to validate and the server keeps no record of what any client knows. - **What a client has learned about a flag lives in the flag record**, not in a second map beside it. `keepFlagIdentity` is the rule that keeps the record's answer across the update that hides the type again, and drops it when the slot empties or takes a flag flying in. - **The flag notices name the player and the flag in their own colours.** `Orin grabbed L/Laser flag` was flat text while `Orin left the game` was coloured; the grab, drop, theft, capture and pause lines now go through the same path, so a callsign wears its roster colour and a flag wears the colour it has everywhere else -- which puts a bad flag's warning on the line that says who picked it up. ## [1.0.87] - 2026-09-08 ### Added - **A phasing tank is clipped at the wall it is in, and sprays light out of the seam.** bzo had the eighth dimension -- what a tank driving through a building sees of it -- but not the other half, what everyone else sees of the tank: it rendered solid with its nose buried in geometry. Now the half inside the wall is clipped away and the cut edge streaks white, opaque at the seam and transparent at the tips, re-rolled every frame so it flickers. `TankIDLSceneNode` and the clip plane beside it (`Player.cxx:938-968`), both from one crossing plane -- `getBoxCrossingPlane`, which is `isCrossing` (`BoxBuilding.cxx:143`), whose box, pyramid and base bodies upstream are the same function. Not OO's alone -- upstream's condition takes a teleporter crossing too, so PZ gets it. The silhouette is upstream's own 40-vertex table rather than bzo's tank mesh, because bzo has several models where upstream has one and the table suits all of them roughly while capping the per-frame cost at a known number. Nothing new goes over the wire: upstream ships a `CrossingWall` status bit, and bzo's client already knows every player's flag and holds the obstacle list. - **`docs/effects-plan.md`**, an audit of every visual effect upstream draws against what bzo draws. All seven `effectsRenderer.cxx` families are present, so the gaps are elsewhere and there are three: tank tracks (#24), teleporter proximity, and weather -- which is map-driven and so shows nothing unless a map asks. Teleporter proximity turns out to be **two** effects off one input, and the bigger one is easy to miss: `SceneRenderer.cxx:1145` blends the whole frame to *yellow* as you approach a portal, reaching opaque about two units out. That flash is what makes a teleport read as a teleport upstream, and bzo hard-cuts with only the sound. The tank alpha fade beside it is the minor half. Both need a graded overlay bzo does not have -- `setBlank` is binary -- and the flash needs an XR decision first, because a full-field flash is a comfort and photosensitivity problem in a headset; the plan records three designs and recommends a vignette. - **`docs/operator-panel-plan.md`**, a plan for the operator panel: staged edits with one confirm and Cancel in place of a button per row, the confirm reading *Apply* or *Restart* by what has been staged, and the rule that every control has to work with left, right and select -- numbers become steppers and booleans become On/Off choices, while text stays reachable in a headset through the system keyboard `beginXRTextEntry` already opens, gated on the `isSystemKeyboardSupported()` capability rather than on being in XR. It records three things worth knowing. **`maxPlayers` caps nothing**: it is read once to supply the default per-team limit and is never enforced, where upstream has a real cap (`autoTeamSelect` returns `ObserverTeam` at `maxRealPlayers`), so a panel row labelled "max players" would need that check built first. A **game mode change is a map change**: not a setting that needs a restart but a new game, which is the same event as a match ending -- `docs/game-modes-plan.md` now says so from the other side, so the two are built as one thing rather than drifting apart. And per-row apply leaked into XR, where the operator menu currently spends two rows on every setting; OK and Cancel remove rows rather than adding them. - **`renderer.stats` can find a leak now.** A client whose frame rate drifts down over an idle hour, and comes back on a reconnect, had no evidence behind it: a leak is a trend and one sample at map entry cannot show one. Every counter that could climb is baselined at the page's first sample and reported as movement since then -- `grew=objects+240,heap+38.5`, absent entirely while nothing moves. The baseline outlives a reconnect on purpose, because a leak the reconnect clears is the case worth catching. New counters beside it: `objects`, every node in the scene graph, which catches "something is added and not removed" whatever it is; `labels`, `tanks`, `shots`, `worldFlags` and `spheres`, bzo's own collections, each of which is added to on one event and removed from on another; and `heap`, the used JS heap in MB where the browser exposes it, absent rather than zero where it does not. **A slow series now runs on every client**, five minutes apart, so a flat page left open produces a trend. The XR series stays at twenty seconds and suppresses the slow one. - **A lost WebGL context is no longer silent.** It is how a client ends up drawing black -- the browser takes the context away and every texture goes with it, which Three cannot re-upload because it does not know they are gone. bzo had no listener, so it had no evidence either. `webglcontextlost` and `webglcontextrestored` now log with the resource counts at that moment, and `contextLost` rides every later stats line. **It caught one within the hour**, which is why the client now recovers rather than only reporting: context lost and restored a second later, with 158 textures and 128 geometries live against a steady state of 92 and 54 -- and the player left looking at black tanks. What survived is what identified it: the barrel looked right and the body, turret and treads were black, and the barrel is the only tank part with no texture at all. Every textured surface was dead, which is wholesale loss rather than one asset failing. So a restore reloads the page, once per session and remembered across the reload, since a driver that keeps taking the context away would otherwise never settle anywhere a log could be read. Why Three did not re-upload those textures itself is unresolved; the reload makes it moot. - **A texture that fails to load says so.** `_getSharedImage` set an error and told its listeners, and nothing logged it -- so a missing file surfaced as a rendering bug instead. `_createTreadTexture` in particular fills `#2b2b2b` when its source has not arrived, which reads as black treads on a lit tank. `docs/audio.md` already refuses fallbacks for a missing sample on the grounds that "a failed load is a broken build and should surface as an error"; a texture quietly going grey was the same failure without the evidence. ### Changed - **One teardown for a tank, with three callers.** The ghost mesh, the projected shadows and now the crossing-wall lights are drawn *beside* a tank rather than under it -- a child would inherit its landing squish and spawn scaling -- so none of them go away when the tank does. Three places did that by hand and had already drifted, only the quit path dropping the shadows, so the new lights were left behind by two of the three the day they landed: a tank that quit inside a wall left its streaks hanging there. `discardTank` is now the only copy. A tank that *dies* inside a wall is the other path and not teardown at all -- it keeps its object and stops being drawn -- so the effect tests `tank.visible`, which is exactly upstream's two early returns in `addToScene` for a tank that is not alive or is cloaked to nothing. The flag alone was not enough to notice, because the tank keeps it on this client until the server's drop arrives. - **Escape backs out one rung at a time, and Settings is the last stop.** Upstream's Escape opens the main menu (`MainMenu.cxx`) and bzo had no key that opened Settings at all, so it was the one dialog a keyboard could not reach. The web spends Escape on its own state first, so it is a ladder: leave chat, then fullscreen -- the browser's own doing -- then mouse steering, then open Settings, and the press after that closes it. Settings is one press from a plain client and three from a fullscreen mouse-steering one, in the order somebody would want them undone, and pressing Escape once too often is always undone by pressing it again. The fullscreen rung claims the press rather than letting it fall through, since whether a browser also delivers the keydown that exits fullscreen is up to the browser; claiming it spends the rung either way rather than doubling up with mouse steering where it is delivered. bzo does not use pointer lock, which is why mouse steering needs a rung of its own. - **Up and down leave a text field instead of jumping the caret.** In the operator panel's MOTD row they moved the cursor to the ends of the text and stranded the focus there, because `canCycleWithArrowKeys` fails a text input and skipped every arrow. They are now handled before that gate: sideways operates a row and up and down move between rows, on every row including a text one, which is the same split an XR thumbstick reads. Left, right, Home and End stay native in a text field, which is what editing needs. - **Shot Max Active is a slider.** A hand-rolled `<` value `>` stepper wrapped onto three lines in the DOM; it now reuses the volume slider's markup, so it is one line, drags with the mouse and steps with the arrow keys. - **A flag is named `ID/Identify` in chat.** Upstream prints the name alone (`playing.cxx:2734`) and bzo's kill notices print the abbreviation alone, so nothing tied the `/ID` on the scoreboard to the flag that does the identifying. The grab, drop and theft lines pair both -- that is where a player learns the mapping, and it fires once per pickup rather than every frame. HUD alerts keep the short name, because `ID/Identify 4.3` is a shake countdown made worse, and team flags stay name-only since `R*` through `P*` appear nowhere else. ## [1.0.86] - 2026-09-08 ### Added - **`/mv`, which upstream has no version of** (#5). Not in bzfs, not in any plugin, and no API call to move a tank either -- it is here because bzo is developed by driving it, and `server.json`'s `testSpawn` already does this on join. `/mv` is the same thing without a restart. `/tp` is left free for the teleporters bzo already has as named obstacles. `/mv x,z` goes there at whatever height the tank fits, keeping its facing; `/mv x,y,z` gives the height, and a facing goes either in a fourth slot after all three coordinates or as a word after them -- `/mv 0,0 e`, `/mv 0,30,0,s`. `/mv <player> ...` moves somebody else, taking upstream's own `<#slot|PlayerName|"Player Name">` target syntax. Coordinates are bzo's world coordinates, the ones `testSpawn` takes. A facing is one of the eight compass points and never a number: bzo's rotation runs anticlockwise from north while a compass bearing runs clockwise, so `90` would have been ambiguous in the one direction that matters. Height goes through `dropSpawnPosition`, the resolver `testSpawn` uses: given and clear it is honoured, so `/mv 0,30,0` puts you up to watch yourself fall; given and blocked it climbs, so a coordinate inside an elevated obstacle comes out on the roof; not given it starts from the ground, so `/mv 0,0` lands on the grass on `hix` and on the centre block on `fountains`. The reply names where the tank actually landed, which is how a mistyped coordinate shows itself. - **`/me`** (#5). `/me smiles` reads as "Tim Riker smiles", and it works to ALL, to a team, to the admin channel and to one player -- because it is reformatted in the message path rather than dispatched as a command, which is where upstream puts it and for the same stated reason: a dispatcher has already thrown the destination away. The wire carries `msgType: 'action'`, which bzo already had and already rendered, so this was an entry point and nothing else. `/me` with no argument answers upstream's sentence and `/mefoo` is not `/me`. - **The operator tier of server commands** (#5): `/kill`, `/say`, `/mute`, `/unmute`, `/mutelist`, `/playerlist`, `/flag` and `/set`. Each sits in front of a function bzo already had -- `/kill` goes through the one death path, so the flag, the lock, the rabbit and the respawn all happen. A mute carries upstream's own sentence and its own exception: somebody who may still send on the admin channel does, which leaves a muted player a way to ask about it. ### Changed - **The Operator panel and `/set` write through one function.** Both now go through `applyServerConfigChanges`, which validates, persists to `server.json`, applies to the live config and broadcasts as one transaction. They are one action with two front ends, and two copies would have disagreed about what a valid value is or forgotten to tell the clients. `/set` reaches exactly the three the panel propagates, and says outright that everything else on this server is a constant. ## [1.0.85] - 2026-09-08 ### Added - **Every notice that names a player says who they are**, not just Identify. "Got shot by", "You killed", the third-person kill line, join, leave and the lock warning all read `<callsign>/<FL> (<Team>)` now, coloured per component as the scoreboard colours them. `formatPlayerLabel` is the one place it is composed, so no two surfaces can describe the same tank differently, and a killer on your own team is `teammate <callsign>` with neither team nor flag -- upstream's own wording (`playing.cxx:4008`). **The team is named where it says something**: a colour team, or the rabbit. A shade inside a team's band reads clearly on a tank and not at all in one line of text, which is why upstream prints the team there and bzo now does too. Rogue, observer and hunter name nothing -- every bzo player has a colour of their own, so `(Rogue)` on every line of an OpenFFA server would be noise. **The flag comes from the kill message, not from the world.** The server drops a victim's flag before it says anyone died, so a notice reading live state never saw one -- "You killed Orin" where it should have said "You killed Orin/ID". `playerHit` now carries what each tank held at the moment of death, as upstream's `MsgKilled` packs `flagType` for the same reason. - **Server commands, and a leading `/` never reaches chat** (#5). A chat line beginning with `/` is now a command or an "Unknown command" reply, asked before any destination -- so a mistyped `/kick bob` no longer announces itself to the room, whichever channel it was aimed at. It needed no client work: bzo's chat entry does not echo locally, so the server declining to broadcast is the whole of it. The commands are `/?` and `/help`, `/<prefix>?` for one command's usage, and the open tier: `/uptime`, `/serverquery`, `/msg`, `/date`, `/time`. Upstream's output formats throughout -- `/?`'s columns filled downward against a 64 character line, `/uptime`'s "1 day, 2 hours" with its full stop, `/date`'s fixed-width `ctime`, and `/msg`'s error wording down to the two spaces after the full stop. Two tiers where upstream has sixty permissions, and `/msg` goes through the same delivery function the chat entry does, so the admin channel's permission check cannot exist in only one of them. - **`docs/commands-plan.md`**, a plan for the rest of the bzfs chat commands (#5): upstream's two-layer split and its sixty per-command permissions, what bzo already has to build on, and the commands sorted by what each one actually needs -- a parser, a small piece of machinery, or a whole subsystem. It also records two things found while writing it: a ban cannot rest on bzo's client address as currently read, and a kick needs an answer to bzo's auto-rejoin or it is a no-op with a rude message attached. - **`"localAdmin": true` in `server.json`** makes a connection from this machine an operator without a bzflag.org login, so a test client can drive the Operator panel and, once bzo has them, the server commands. Off by default, and the rule is deliberately two-part: bzo does not terminate TLS, so a public deployment is behind a reverse proxy, and a proxy on the *same host* makes every request in the world arrive from `127.0.0.1`. A loopback peer is not enough -- the request must also carry no `X-Forwarded-*` header, which is the half a remote client cannot forge. Every grant is logged. ### Fixed - **A pad flush with the ground no longer stops a burrowed tank.** An obstacle with no height sitting on the ground is a pad rather than a block, and bzo now makes it passable to tanks and shots without the map saying so. Upstream's own words, on the base (`BaseBuilding.cxx:77`): *"if a base is just the ground (z == 0 && height == 0) no collision -- ground is already handled"*. Upstream writes that guard on the base alone; its box arithmetic has none, and a `BU` tank drives below zero, so its own span reaches up through a pad's `[0, 0]` and it stops dead on one. Flat bases were affected too. A pad is still drawn and still labelled -- the passability flags are read only by the collision code, never by the renderer. It is no longer a *support surface*, so the debug geometry no longer outlines one as the thing you are standing on, which is correct: you are standing on the ground. - **A box flush with the ground no longer z-fights it.** A flat base carried a depth bias for being coplanar with the ground and a flat box did not, so one was a coin toss per pixel. Flat boxes now share a material with that bias, which costs an ordinary box nothing. - **An observer is white on every server** (#42). `getInitialPlayerColor` keyed the answer on whether colour teams were on, so with them off -- OpenFFA, and Rabbit Chase, where observer is the only team anyone can ask for -- an observer kept whatever colour the whole wheel had handed it, and the entry dialog previewed Observer in that colour rather than in upstream's flat white. An observer is never drawn and its row is read by name, so there is nothing for a distinct colour to distinguish; `getJoinPlayerColor` now gives it `Team::getTankColor`'s white whatever the team mode, and coming back to play takes a fresh colour, since the white it was wearing is nobody's. - **An observer's row shows no score and no rank** (#42). Upstream draws neither the score column nor the kills column for one (`ScoreboardRenderer.cxx:829`) and bzo was drawing `0 / 0`: an observer cannot kill or die, so that is the absence of a score rather than a score, and a rank would name a place in a queue it can never be picked from -- `canBeRabbit` refuses an observer outright. The blank line upstream drops in front of the first observer (`:562`) comes with it, on both the flat and the headset boards, because sorting them last says where they are and only the gap says they are a separate group. - **The rank column is named** (#42). Upstream keeps the percentage inside a column it labels only "Score" and never names the rank, which leaves a reader to guess what the number in front of their kills is. The heading now reads `Rank Kills / Deaths` on a Rabbit Chase world, abbreviated to `Rank K/D` on the headset's narrower panel. ### Changed - **`maps/flagbuffet.bzw` names its flag zones.** Each of the 41 flag zones has a flush box under it with the zone's own dimensions and a name like `L_Laser`, so the debug labels name every flag on the buffet. ## [1.0.84] - 2026-09-08 ### Added - **Rabbit Chase, upstream's fourth game type** (#42). One rabbit against every hunter: `"rabbit": "score" | "killer" | "random"` in `server.json` and `-rabbit [score|killer|random]` in a map's `options` block, both defaulting to `score` for a bare switch as bzfs does. Turning it on turns the colour teams off and says so in the log, which is `CmdLineOptions.cxx:1586` exactly, and makes it and CTF mutually exclusive by construction rather than by a check. **Nobody picks a team.** The entry dialog offers observer and nothing else; everyone else joins as a hunter and the rabbit is anointed, never asked for. Asking for a colour team is read as "play" rather than refused, which is what `autoTeamSelect` does with it. **The anointing is upstream's**, in `server/teams.cjs` as pure functions: `Score::ranking`'s discounted win rate, the "prefer anyone alive who is not the old rabbit" order, `-rabbit killer`'s shortcut for whoever shot the rabbit, and `-rabbit random`, which replaces the ranking rather than the selection. It runs when the rabbit dies, self-destructs, pauses, leaves, goes to observer or rejoins, and whenever a player spawns while the world has no rabbit. **Hunters are team mates, so hunter-on-hunter fire is team killing** -- except on the rabbit, and except for a deposed rabbit until its next spawn. That window is `wasRabbit`, and it is the whole exception. Team scores never move; player scores work as usual. **The rabbit is the one tank in bzo that wears a team's colour**, upstream's light grey, white on the radar and ringed there in upstream's hunt cyan. Hunters keep the per-player colour every bzo player has, because one colour for the crowd is what hunter orange was for and bzo has a better answer to it. The scoreboard marks the rabbit's row, and the headset reads both through the panels it already draws -- the XR radar is textured from the same canvas. **The board is sorted by rank rather than by score**, which is what `newSortedList` does on a Rabbit Chase world, with a `%` column in front of the score saying what it was sorted by. The order is now a prediction of who gets the rabbit next: a player with no record at all ranks 50% and sits above anyone whose win *rate* is worse than even, however far ahead they are on kills. ### Changed - **Identify names the flag and the rabbit.** "Looking at" and "Locked on" now read `<callsign>/<FL> (rabbit)`, coloured per component as the scoreboard row is, which is the same information upstream's `<callsign> (<Team>) with <Flag>` carries. HUD alerts gained the `segments` a chat line already had, honoured by both the DOM notice column and the XR notice panel. - **Every death runs one code path.** A self-destruct and a capture each had their own copy of the sequence a death performs, and both had drifted: neither cleared the guided-missile lock, so a missile went on steering at a tank that was already exploding, and the self-destruct had to be told separately about deposing the rabbit. `applyDeath` is that sequence, and the three callers now differ only in what a death *costs* -- which is the part that really does differ, since a capture deliberately scores nobody a death. ## [1.0.83] - 2026-09-08 ### Added - **Global login with a bzflag.org callsign, and admin keyed to it.** The Player Options dialog carries a **Global login** row: it sends the browser to bzflag.org's own login form, which is what lets bzo check an identity without ever seeing a password -- `misc/checkToken.php` refuses a site that collects one itself. bzflag.org sends the player back with a token, the server asks the list server `CHECKTOKENS` whether it is real, and what comes back is a BZID, a confirmed callsign, and membership of the groups the server asked about. **An authenticated player is their global callsign.** The name is taken from the session rather than from whatever the dialog sent, and it outranks a typed one: an unauthenticated player holding it is renamed in place and told why, and the same account arriving on a second device takes the identity with it, dropping the older device and its session -- without which bzo's own rejoin-without-asking would trade the name between two devices forever. **`isAdmin` now means authenticated *and* a member of a group named in `adminGroups`.** Neither half is client-supplied: the session is looked up from a cookie the server issued, and the groups are what bzflag.org answered. This replaces the courtesy gate where any name that was not `Player <n>` counted -- keeping it would have made the login decorative. `example-server.json` ships bzflag's own `DEVELOPERS` and `BZADMIN`, so a fresh install has somebody who can operate it; an operator who wants only their own authority empties the list. **`@` and `+` beside a callsign**, on both the flat and the headset scoreboards, in cyan and as a field of their own rather than as part of the name -- `@` for an admin and `+` for an authenticated player, which is exactly what `ScoreboardRenderer.cxx:712` draws. A name may not begin with `@`, `+` or `-`, so nobody can wear an indicator they were not given. The browser holds one opaque value and nothing else: 32 random bytes in a host-only `HttpOnly; Secure; SameSite=Lax` cookie, keying an 8 hour server-side session that survives a restart. Every attribute stays on the server, so a forged cookie is anonymous rather than privileged. See `docs/login-plan.md`. ### Changed - **The tank carousel previews a tank rather than its geometry.** The preview is built by the same `createTank` the world uses, so it carries the tank texture in the colour it will spawn in, the treads carry theirs, and a wheeled model gets wheels; it used to load the model separately and draw an untextured default. It also fills the dialog now -- the canvas follows its own CSS size through a `ResizeObserver` instead of the size the dialog was when it was hidden, and the model is fitted to the frame rather than to a fixed 2.7 units. It starts side-on, which says more about which tank it is than the back of one does, and the colour follows the **staged** team, since the dialog's job is to show what the staged choices would look like. - **A click outside the Player Options dialog closes it**, putting the staged draft back exactly as Cancel, the `[X]` and Escape do. Every other dialog already closed that way, and Escape already closed this one. ### Fixed - **A kill no longer moves the team score in capture the flag.** Upstream gates its whole per-kill team-score block on the game type being a free-for-all (`bzfs.cxx:3539`): in `ClassicCTF` a capture is the only thing that moves a team's tally, which is what makes a capture worth crossing the map for. bzo asked only whether colour teams existed, so on any team map with a base -- `hix.bzw` included -- kills and captures both counted, and eight kills were worth a flag. Player scores are untouched; the team rows are what change. The type itself is now named once, as upstream names it: `GAME_TYPE` is `OpenFFA`, `TeamFFA` or `ClassicCTF`, derived from the two questions bzo already asked -- are there colour teams, and are there bases -- and logged at startup. `RabbitChase`, upstream's fourth, is still missing; see `docs/game-modes-plan.md`. ## [1.0.82] - 2026-09-08 ### Added - **A direct message announces itself.** `SFX_MESSAGE_PRIVATE` was the one message sound bzo was missing while having the feature that plays it: a DM arrived silently where a team message beeped. Upstream's own trigger -- addressed to me, sent by a player rather than the server, and at most once every two seconds. Each of the three message sounds keeps a clock of its own, because upstream keeps a separate `static lastMsg` per branch, so a team message no longer silences the private one that arrived beside it. - **An admin channel, and the concept of an admin.** Upstream's `AdminPlayers` destination, gated both ways: sending needs permission and so does receiving, and a sender without it gets upstream's own sentence -- "You do not have permission to speak on the admin channel." -- rather than having the message dropped silently. An admin message is marked `[ADMIN]`, goes to the Chat tab as a team message does, and plays `SFX_MESSAGE_ADMIN`. ADMIN joins the destination dropdown, hidden for anybody the server would refuse. bzo has no login, so there is nothing to key a permission to. **An admin is a player whose name is not the default.** `nameCheck` calls an empty name `Player <n>` and refuses that shape to anybody whose number it is not, so the default cannot be claimed and a name that is not the default was typed on purpose. It is a courtesy gate rather than a security one, and `isAdmin` is the one function a real login would replace. Closes #41. ### Changed - **The Operator panel is for admins only.** Its four messages -- `getMaps`, `setMap`, `uploadMap`, `setOperatorConfig` -- are refused on the server for anybody who is not an admin, and the Operator button is disabled rather than hidden, since a control that is plainly unavailable says more than one that is missing. Any connected player could previously change the map or the server config; the standing decision that this was fine during development is withdrawn, and the gate is server-side because a client can draw itself whatever it likes. ### Fixed - **A world weapon's shot carried the wrong reserved id.** `ServerPlayer` is 253; 252 is `AdminPlayers`, which the admin channel above is upstream's counterpart of. Nothing broke -- a bzo player id is the decimal *string* of its player number, so neither value could ever collide with a real player -- but the constant cited `ServerPlayer` while holding the id next to it. ## [1.0.81] - 2026-09-08 ### Added - **World weapons.** A BZW `weapon` block is a gun the world owns: it fires on a timer with nobody driving it, and its shots kill whoever they reach. `fountains.bzw` is built entirely out of them and until now loaded as nine boxes and nothing else. `position`, `rotation`, `tilt`, `type`, `initdelay` and `delay` are all read, and `delay` is a **list** upstream cycles a shot at a time, so a map can give a rhythm rather than a rate. One shot per weapon per tick at most and then the clock is caught up, which is upstream's own "eat any shots that have been missed" -- a server that stalled does not fire a burst to make up for it. A world weapon's shot carries upstream's `ServerPlayer` id and the rogue team, so it takes no shot slot, waits on no reload, and is everybody's enemy. A tank killed by one gets a death, nobody gets a kill, and the victim's team loses a point. The notice is upstream's own whole phrase rather than a name -- `gotBlowedUp` throws the "Got shot by " prefix away when the killer has no roster entry and says "Killed by the server", which every world weapon kill does. There is nothing to draw: a world weapon is not an obstacle and has no visible component upstream either, which is why a map that wants one seen puts a box under it. `trigger` and `eventteam` make an event-fired weapon rather than a timed one; bzo has no event hooks to hang one on, so a map using either is named on load and that weapon fires on its timer. Closes #40. - **`maps/fountains.bzw`**, upstream's own world-weapons demonstration, tracked as the map that exercises them: three shock waves on box roofs, four rapid-fire guns firing inward from the corners, and two lasers down the length of the map. It declares no `world` block, which is what turned up the world-size default below. - **A `testSpawn` is dropped onto the geometry that actually loaded.** The coordinates are typed by hand against one map, and a point inside a building spawns a tank that cannot move -- which is what `server.json`'s own spawn at the origin does on `fountains.bzw`, where a 60-unit box sits there. `DropGeometry::dropPlayer` has two branches and both are here: a clear point falls to the highest flat top under it, and a blocked one *climbs* to the lowest flat top it fits on. Resolved once when the world loads and held in memory for as long as that world is; `server.json` is never written back to, because the coordinates in it are what the operator meant. A spawn with nowhere to stand is named on load and that player spawns at random instead. ### Changed - **The default world size is upstream's 800, not 400.** `_worldSize` defaults to 800 and is the full width, so a map with no `world` block spans +/-400. bzo halved that, which put `fountains.bzw`'s two towers -- at +/-200 with a ten-unit half extent -- straddling the boundary wall where upstream has 190 units to spare. It affected any upstream map that omits the block, not just that one. - **The map load says what it dropped.** A `weapon` keyword bzo does not read, or a weapon type it does not have, is now named at load the way an unread `zone` keyword and an unread `-set` variable already were. A partial load should be visible rather than looking like a clean one. ## [1.0.80] - 2026-09-08 ### Added - **`BU` Burrow and `PZ` Phantom Zone, and with them bzo's flags are done (#6).** Every flag BZFlag has is in bzo except `WA` Wide Angle, which is deliberately out for good rather than pending: it widens the field of view, which the headset runtime owns in VR. That closes the flag work -- four team flags and forty-one of upstream's forty-two superflags, all of them with an answer on desktop, on a phone and in a headset. - **`BU` Burrow.** `getGroundLimit` is upstream's `groundLimit`: `_burrowDepth` -1.32 for Burrow and zero for everybody else, and everything in bzo that clamped a tank at zero now asks it instead -- the move resolver, the ground snap, both ends' dead reckoning, and a server check, because being down there is the whole of the flag's immunity. That immunity is geometry and not a rule: a tank at `_burrowDepth` reaches up to 0.73 and a shell leaves a muzzle at 1.57, so the ordinary height gate refuses the hit, exactly as upstream's motion bounding box does. A shot fired *by* a burrowed tank starts at 0.25 and is inside that band, so two burrowed tanks can shoot each other, and a shock wave still reaches down. The price is upstream's own exclamation mark: **anybody** can drive over a burrowed tank, not only Steamroller -- and neither can do it from below ground, which is what stops two burrowed tanks killing each other the instant they meet. Digging in is four times gravity, the speed and turn handicaps (`_burrowSpeedAd` 0.80, `_burrowAngularAd` 0.55) are read off where the tank is rather than off the flag, and the radar is capped at a quarter of its range while under. - **`PZ` Phantom Zone.** Crossing a teleporter does not move a Phantom Zone tank: it toggles the zone and plays SFX_PHANTOM instead. Zoned, the tank drives through buildings on `OO`'s machinery, may fire from inside one -- the exception upstream writes into its own "allowed to shoot" test -- and fires bullets that pass through walls. The two hit rules only make sense as a pair: a zoned tank is reached by a super bullet, a shock wave or another zoned tank's bullet and by nothing else, and a zoned bullet reaches nobody who is *not* zoned, so being zoned costs you every target that is not. The state is the server's, kept on the flag entry beside everything else about a flag, and sent as the state it is rather than as a toggle so a client's own prediction converges on it. Upstream's `setInvert` is not a colour inversion despite the name -- it swaps the ground for `zoneGroundTexture` -- so bzo swaps the one ground mesh's map, which is also the only form of that effect with any meaning in a headset. A zoned tank is drawn at a quarter alpha for everybody, itself and a Seer included. The flag is dropped from the pool on a map with no teleporters, since there would be no way to switch it on. ### Changed - **`docs/flags-plan.md` is gone, replaced by `docs/flags.md`.** The plan was a staging document -- what upstream does, which phase each flag belonged to, and the list of what was still missing -- and with every flag in, all of that is history the changelog already holds. What replaced it is a reference for the system as it stands: where each part of it lives, and every place bzo's flags deliberately differ from BZFlag's, which is the one thing about them that was written down nowhere else. Per-flag mechanics are not repeated there; they stay in comments beside the code, each citing the upstream file and line, where they cannot drift. Code comments name the flag they are about rather than the phase it arrived in, and nothing in the code or the docs describes a flag as missing or pending any more. - **The README says `WA` Wide Angle will not be implemented.** It widens the field of view, which the headset runtime owns in VR -- it sets the projection from the device's optics, and a client that overrode it would either be ignored or make people ill. A flag that is a real penalty in a browser and a no-op in a headset is worse than an absent one, because it looks like it works and the player cannot tell, and keeping VR honest matters more than carrying the flag. So it is out for good rather than pending, and the README says what that means for a map written for BZFlag: `WA` is never spawned, a `zoneflag WA` is ignored, and the skipped type is named once in the server log at load, so the map loads and plays with nothing silently lost. ### Fixed - **A burrow read as a fall, so climbing out of one landed the tank.** Upstream's `location` stays `OnGround` through the whole dig-in and the whole climb back out, because only a z *above* zero makes a tank `InAir` -- so its Falling status never sets and it never "lands". bzo infers both from the vertical velocity in a movement packet, and put landing rings and a landing sound on the screen every time somebody dropped the flag. A tank at or below ground level is no longer read as falling; the ground limit is clamped only on the way down, which is upstream's own condition and what lets a tank climb out rather than be pinned; and the creep that lifts it out is a floor recomputed from where the tank is rather than momentum it keeps, since a few stored centimetres of rise are all it takes for bzo to call the tank airborne. ## [1.0.79] - 2026-09-08 ### Added - **`OO` Oscillation Overthruster (#6).** A tank that drives through buildings, and cannot reverse, shoot or drop its flag while it is inside one. Upstream's rule is not that a phased tank stops colliding: `getHitBuilding` finds the obstacle exactly as it would for anyone and then decides whether the tank is *expelled* from it, so `phasedObstacleExpels` in the collision pair is that decision and the three things that still expel are upstream's -- a wall, which is bzo's world border; a teleporter, so `OO` crosses one rather than driving through its frame; and a reverse at ground level, which is what stops a tank outside a building backing into one. Nothing else is exempt, so an `OO` tank sinks through a roof rather than resting on it, which is what upstream does too. The server reads the same flag: driving through walls is the largest prize a modified client could claim, so its collision check phases only for a tank actually carrying `OO`, and it refuses a shot or a flag drop from inside a building on its own account. - **The eighth dimension, so a phased tank can see the building it is in.** An obstacle's faces are back-face culled, so from inside one the walls are not there at all. Upstream's answer is two scene nodes per obstacle and bzo draws both: `EighthDimSceneNode`'s loose triangles in random colours at random alpha -- 60 for a box or a base, 20 for a pyramid -- and `EighthDBoxSceneNode`/`EighthDPyrSceneNode`'s white wireframe of the obstacle around them. They are built the first time a tank is inside that obstacle rather than with the world, because a map has hundreds of obstacles and no frame draws these until somebody carries the flag, and a pyramid's envelope comes from bzo's own pyramid geometry rather than upstream's `slope * hypot`, which is a cone and knows nothing of an inverted pyramid. All sixty triangles are one mesh: the per-triangle colour and alpha ride on a four-component vertex-colour attribute, which is what upstream's single `glBegin(GL_TRIANGLES)` loop with a `myColor4fv` per triangle amounts to, so a building costs two draws -- the cloud and the wireframe -- and never sixty. A node is attached to the scene while the tank is inside its building and detached when it leaves rather than merely hidden, because `updateMatrixWorld` walks the graph whatever is visible. ### Fixed - **The help panel opened with the end of the document on screen.** `?` showed the licence paragraph and the source-code link, with the title bar and the close button scrolled off the top. Opening a dialog focuses its first control other than the close button, which on a panel of rows is the first row and in the help panel is the first link in the text -- and that link is in the last paragraph, so focusing it scrolled the page it is meant to be read from out of view. A dialog marked `data-dialog-kind="document"` now opens at the top, on its close button, which sits in the title bar where focusing it scrolls nothing. ## [1.0.78] - 2026-09-08 ### Fixed - **A dead tank could keep shooting for the whole five seconds of its death camera.** Nothing on the client marked you dead: the server sends no state with a kill -- the next state it sends for that player is the respawn -- so `isMyTankAlive` went on answering with whatever it last heard, and the fire path had nothing to test. `handlePlayerHit` now clears the victim's health where it hides the tank, which is upstream's `setStatus(getStatus() & ~PlayerState::Alive)`, and `shoot()` refuses while dead or paused, which is `LocalPlayer::fireShot`'s "make sure we're allowed to shoot". The server already refused both -- `getShotRejection` names them -- but it was running in `warning` mode, where a finding is logged and honoured, so the shots landed. An honest client must not send the packet in the first place. Measured with a `TH` probe holding the trigger through its own death: seventeen shots before, none after. - **And could shoot across a reconnect, before the join was confirmed.** bzo reconnects on its own, and between the socket opening and `playerJoined` landing the client still carries the last session's state: alive, holding the trigger, standing where it used to. Three shots went out inside 350ms of `ws.open` in testing, all of them logged `dead player cannot shoot`. `shoot()` now waits for the join, which upstream has no need to guard because it has no tank at all until it has entered the game. ### Added - **`TH` Thief, completing phase 11 (#6).** The only shot in the game whose outcome is neither a death nor nothing: a thief's beam takes the flag the tank it reaches is carrying and leaves the tank alive. Everything else about the flag is the price of getting close enough to fire it -- half a tank's size (`_thiefTinyFactor` 0.5), two thirds again its speed (`_thiefVelAd` 1.67, which beats `V` High Speed's 1.5), twelve shots a reload (`_thiefAdRate` 12), and a beam that reaches four tenths of a shell's range. `ThiefStrategy` is `LaserStrategy` with different numbers, so it is a beam like `L` and takes the path the laser already walks -- cyan for everybody, where a laser wears its shooter's colour, because what a thief beam says is "that was a theft" rather than who fired it. - **Three of upstream's hit rules stop being about damage for it.** You can never rob yourself, a team mate's flag is fair game even with team kills off -- "Thief can still take a teammate's flag" is upstream's own comment -- and a tank with nothing to take does not block the beam, which is `ThiefStrategy::isStoppedByHit` returning false. Shield does not save you either: upstream tests for Thief *before* it blows anybody up, so a shielded tank simply loses the Shield to the thief. - A theft spends the Thief flag itself and costs the thief `_thiefDropTime`, half the world's shot life, before the trigger works again. `TH` is the only good flag in the world with a single grab in it, which is upstream naming it beside the sticky flags in `FlagInfo::addFlag`. - `thief.wav` from upstream, played for the shot rather than for the theft, as upstream plays it. ## [1.0.77] - 2026-09-08 ### Changed - `[CHAT]` log lines follow the log conventions: a player in quotes and a team in brackets, so `[CHAT] "t5810"->[ROGUE]:` needs no word "team" in it -- the bracket is what says which of the two a name is, exactly as the quotes are. AGENTS.md now states that half of the rule alongside the quoted-name half. - The chat input row reads `[Send] [target] [input]`. Send and the target list are used one after the other, so they sit together instead of with the input between them -- which also puts the row in the order it is used, left to right. ### Fixed - **Every dialog but Settings opened off the bottom of a tall page.** Help, the operator panel, audio settings and the entry dialog were `position: absolute`, and body is not a positioned ancestor -- so `top: 50%` centred them on the *document* rather than on the window, and a document taller than the window put them below the middle of what was on screen. They are `fixed` now, like `#settingsHud` always was, and the centring lives once in the block all five already shared instead of five times over. Their height also takes `svh` alongside `vh`, for the reason the on-screen buttons already do: on mobile Chrome `vh` is the viewport with the URL bar retracted, so a dialog sized in it is taller than the window it has to fit. ### Added - **Team chat, as a `TEAM` entry in the chat destination dropdown.** Upstream has no team *tab*: its console tabs are All/Chat/Server/Misc (`ControlPanel.cxx:252`), which is exactly what bzo mirrors, and a team message goes to the **Chat** tab marked `[Team]` (`playing.cxx:3286`) with `message_team.wav` when somebody else sent it, no more than once every two seconds. bzo does the same. - **A team line carries two colours, where upstream has only one to give.** The `[Green]` label -- named by its colour alone, as `getPlayerFlagLabel` names a team flag, because the bracket and the colour already say "team" -- takes the team's own colour -- the one its flag, its radar blip and its heading marker already wear -- and the callsign takes the *sender's* own. Upstream paints the whole line the team's ANSI code, which it can because its team mates all share one colour; bzo shades them apart, so it has a second colour to spend and spends it on saying who is talking. Chat entries now carry optional coloured runs to make that possible, and both renderers draw them: the DOM one as spans, the XR panel run by run, carrying the width left over so a long line still stops at the panel edge. - Who a team message reaches is `player.team === sender.team`, sender included, with no exception for Rogue or Observer -- bzfs's own team dispatch sends to every player whose `isTeam` matches, which is the rule the `voice-channels` pair already spells out for the Team voice channel. The two now agree. - The help panel and the README list the `identify` binding on every surface it has -- `I`, right click, the on-screen `◎` button, either VR thumbstick press and either gamepad shoulder -- and the panel has a **VR & Gamepad** section, which it had none of before: the trigger, grip, A and B bindings were discoverable only by pressing things. ## [1.0.76] - 2026-09-08 ### Added - **`GM` Guided Missile, completing phase 12 (#6).** A shot whose heading is a new answer every simulation step, turned toward whichever tank its shooter has locked at `_gmTurnAngle` 0.628319 radians a second -- azimuth and elevation separately, each at the full rate, so a missile that has to come around and climb does both at once. Life `_gmAdLife` 0.95, the world's own speed and reload, and it never ricochets on any world: `GuidedMissileStrategy::checkBuildings` has no reflect branch, so it explodes on the first building it reaches. `_gmActivationTime` 0.5 gates *hits* rather than steering, which is what stops a missile locked onto a target two lengths away from killing its own shooter as it comes round. - **The lock is the server's, which is a deliberate departure from upstream.** Upstream runs `setTarget()` on the shooter's own client; a lock steers a real weapon, so bzo decides it where hits are decided. Nothing is lost -- upstream's scan reads the tank's own heading, not the camera's -- and the identify binding now does something for a tank on every surface: `I`, right click, the on-screen `◎` button, either VR B button and either gamepad shoulder. Two cones, one press: `_lockOnAngle` 0.15 locks for a player with a missile to steer, and the wider `_targetingAngle` 0.3 only names the tank, which is what identify does for anybody else. The touch button was hidden outside observer mode until now, waiting for exactly this. - A lock is broadcast, because every client steers every missile from the same shared `steerGuidedShot` and has to know what it is steering at. Whether a lock is still live is asked wherever it is *read*, so a target that dies, pauses or ducks under `ST` is dropped by the missile on the same step on every screen, with no packet. Upstream refuses Stealth outright here, with no `SE` exemption: where a missile may fly is not a matter of what somebody can see. - **A lock lapses with the missile**, which upstream does not do -- nothing in its `LocalPlayer` clears a target on a flag change, so its marker outlives the flag. When `GM` leaves the hand and the last missile leaves the air, the lock and its bracket go: a bracket over a tank you have no way to shoot at is saying something that is no longer true. Dropping `GM` with a missile still flying keeps the lock, because retargeting that missile is what the flag's help text promises. - **The lock-on bracket stands in the world, not on the screen.** Upstream projects the target and clamps the bracket to the window edge (`HUDRenderer.cxx:1314`), which means nothing in a headset; bzo draws upstream's proportions as a sprite at the locked tank, in the colour that tank is drawn in, with depth testing off so a target that ducks behind a wall still says where it went. What that costs is upstream's edge clamping, so the heading tape carries a marker for the locked tank instead -- the same way a team flag is found, and the answer for a target off the side of the screen in the window and in VR alike. - `missile.wav` when a missile is fired, and `lock.wav` with a "locked on me" warning for the tank being shot at, at most every 0.75s. Upstream plays the warning when a `MsgGMUpdate` naming you arrives, which is a missile *already in the air* -- so it lands on the shot and on a retarget, never on somebody taking the lock, which warns nobody. - `server.log` records lock changes -- `"name" locked on "name"` and `"name" lost the lock` -- which is how a retarget mid-flight can be read back against the shot's own `[shotBegin]` and `[shotEnd]` headings. - The bolt is upstream's default-quality one: a billboard textured from `missile.png`, a 4x4 sheet stepped one cell a frame, trailing a smoke puff every `gmPuffTime` 1/8s. The modelled missile with fins is behind `useQuality() >= 3`, so bzo draws the variant upstream draws by default. The colour stays bzo's own -- upstream paints every missile the same orange, and bzo colours every shot by who fired it, which is the shot you most want the owner of. The tail is re-laid along the heading each step, since a missile's is the one trail that has to follow. ### Changed - `pickTargetInSights` and the two cone angles moved from `roam.mjs` into the flags pair, with the cone as an argument. Upstream answers both questions with one `setTarget()`, and the server needs the scan now that it owns the lock. ## [1.0.75] - 2026-09-07 ### Changed - **Inertia is BZFlag's now, and there is none by default.** bzo smoothed the stick through five rates of its own -- `forwardAccel`, `reverseAccel`, `forwardDecel`, `turnAccel`, `turnDecel` -- with no upstream counterpart, which gave every tank inertia BZFlag does not have (about `-a 2.25 2.36`) and no way for a server or a map to say otherwise. Those five config keys are **removed**. In their place is upstream's own `doMomentum`: an acceleration limit in real units applied to the velocity, set by `-a <vel> <rot>` and defaulting to `0 0`, which means no limit -- so a bzo tank now reaches full speed in one frame, exactly as a BZFlag tank does. Set it in `server.json` as `linearAcceleration` / `angularAcceleration`, or in a map's `options` block as `-a`, which is both places upstream takes it. The model lives in the flags pair so the client that drives the tank and the server that checks it run the same code against the same numbers. ### Added - `A` Agility triggers on a change of *stick*, which is deliberately not what upstream does. Upstream compares against the previous `desiredSpeed` fraction clamped to [-0.5, 1], and that clamp invents a change that never happened: a held partial stick around 0.4 to 0.7 re-triggers the boost forever, so an upstream Agility tank holding half forward sits at 28 units a second indefinitely -- faster than anybody at full throttle, without moving the stick. Agility rewards changing direction, and holding still is not changing, so half stick outweighing full stick is a bug rather than a rule. It is invisible on a keyboard, where the stick is only ever 0 or +/-1; bzo has analog input everywhere. - `M` Momentum (#6), which completes phase 5. It composes with the world's acceleration limit rather than replacing it, by adding reciprocals the way two constraints on one quantity combine. Upstream substitutes instead, and since `_momentumLinAcc` defaults to exactly what `-a 1 1` gives, upstream's `M` does nothing at all on such a world and is an *upgrade* on anything heavier -- a strange thing for a bad flag to be. bzo's reduces to upstream's figure on a world with no inertia (20 u/s²) and is a handicap everywhere else. - The server's acceleration check runs that same model rather than a bound of its own, in real units on both sides. With no inertia it finds nothing, which is correct rather than lax -- a tank with no limit really can reach full speed in a frame, and the `fs`/`rs` clamp is what bounds it. bzfs makes the mirror-image trade, skipping its high-speed check when inertia is on. - `RC` ReverseControls, `FO` Forward Only, `RO` Reverse Only, `LT` Left Turn Only, `RT` Right Turn Only, `BY` Bouncy and `TR` Trigger Happy (#6, phase 5's bad flags -- everything but `M` Momentum). Five clamp the stick and two act without it. The clamps go on the raw stick where upstream puts them, so the acceleration smoothing and Agility's window downstream see what the tank was actually asked to do; they stay client-side, as upstream has them, because `fs` and `rs` are measured from the resolved displacement and a tank sliding along a wall legitimately reports a sign it never asked for. `RC` pressing forward drives backwards at half speed, since the negation lands before the reverse-speed clamp -- upstream's order, and the right one: the flag reverses the controls, it does not turn the tank around. `BY` is out of the jump gate entirely and bounces on a world that forbids jumping, each bounce a random quarter-to-full of the world's jump velocity. `TR` skips the reload gate and waits only for a free shot slot, which is not a rate increase because that is the rule the server already holds every shot to. - `testSpawn` in `server.json` takes a list as well as a single entry, so moving a probe from flag zone to flag zone no longer costs the person testing beside it their own fixed spawn. ### Fixed - A client told to reload now waits for the server to answer `/api/ready` before leaving the page. The socket has always retried forever, but `location.reload()` fetches over HTTP -- and a reload that lands while the server is restarting gets the browser's own error page, with no script left to try again, so the tab stays dead until somebody presses reload by hand. A map change is the worst case, because it tells every client to reload at the same moment it takes the server away. - The server inferred a jump from a flat `vv > 10`, which predated every flag. A `BY` Bouncy bounce starts as low as 4.75 at bzo's default, so the server missed those jumps outright and went on extrapolating the tank along the ground while it was in the air. The threshold is a tenth of the world's own jump velocity now, so it follows a server that has tuned the jump. - `V` High Speed, `QT` Quick Turn and `A` Agility (#6, phase 5's three good flags). Two multipliers on the world's own tank speed and turn rate -- `_velocityAd` and `_angularAd`, both 1.5 -- and one flag with a clock: a change of more than `_agilityVelDelta` 0.3 in what the stick asks for buys `_agilityAdVel` 2.25 for `_agilityTimeWindow` 1.0s, halved to 0.15 when the new request is a reverse. Upstream's details come with it: the window does not extend while it is open, and the fraction the change is measured against is clamped to [-0.5, 1] so an already-boosted tank still has to make a real input change to earn the next boost. `getMotionEffects` joins the flags pair, shaped like `getShotEffects`, and the eight bad movement flags extend that table rather than replacing it. - Move packets now say how fast a tank is going rather than how fast it is going *for its flag*: `fs` and `rs` are fractions of the world's base speed and turn rate, so a boosted tank reports more than 1 and every reader -- the server's extrapolation, the client's remote extrapolation, the tread animation -- is already correct with no flag state of its own. The server clamps the reading to the flag's largest possible factor rather than its instantaneous one, because Agility's window is the client's to run and a ceiling costs no state. Agility is exempt from the forward half of the acceleration check for the same reason a `WG` tank is exempt in the air: the boost lands all at once, so no acceleration bound describes it. - The help panel lists good and bad flags sorted by abbreviation. Team flags keep BZFlag's own red/green/blue/purple order, which is the numbering the rest of the game counts in; the superflags had no order at all, since upstream's own help walks a `std::set<FlagType*>` and bzo's table was in whatever order the phases landed. - Log lines drop the redundant `Player` before a quoted name -- the quotes are what identify it -- and both names in a `[Voice]` line are quoted. `Player 3` with a bare number stays, where the word is the only thing saying what the number counts. `joining game as` is now `joining as`. ## [1.0.74] - 2026-09-07 ### Added - `SR` Steamroller and `G` Genocide (#6, phase 6). Steamroller kills by touch, within the victim's radius plus `_srRadiusMult` 2.0 of the roller's -- the only rule in the game that runs off nothing but where two tanks are, so it gets a sweep of its own in the game loop. The reach is measured in tank radii and so takes bzo's tank rather than BZFlag's, which is the other side of the rule the shock wave's radii follow; both radii are scaled by what each tank's flag does to its size, so `T` is harder to run over and has a shorter reach. Vertical separation counts double, as upstream weighs it, so nobody is squashed through a roof. Genocide kills the dead tank's whole team, read off the shot, only on a world with teams and never for rogue -- and it is out of the flag pool entirely without them, as `JP` and `R` already are on worlds that make them pointless. Both are decided on the server, where upstream decides them on each client. - `-noTeamKills` and `-tk`, upstream's two team-kill switches, which are not the same switch and run in opposite directions. `-noTeamKills` turns friendly fire off -- "Players on the same team are immune to each other's shots. Rogue is excepted" -- and covers shots, shock waves and being run over together, because bzo asks it in the one place that decides a hit. `-tk` is the *opt-out* from upstream's default that a team killer dies for it, so bzo's default is the strict one. Either way a team kill scores the killer a death rather than a kill, so it never counts towards shaking a bad flag. Both reachable from `server.json` and from a map's `options` block. `-tkkr` and `-tkannounce` are not implemented. - `steamroller.wav` (`SFX_RUNOVER`), played in place of the explosion when a tank is run over rather than on top of it, and the two death notices that go with the new reasons -- "Got flattened by" and "Teammate hit with Genocide by", in upstream's own words. - `maps/flagbuffet.bzw` has two bases -- green east, purple west -- and asks for `-c` and `-mp 10,0,10,0,10,10`, so the flags that only mean something between teams have somebody to mean it against: team flags and their capture, Genocide, Masquerade, Colorblindness, a shock wave or a steamroller taking a team mate with it. Green and purple are the two with bases; red and blue are closed, because on a map this size two more bases would sit empty, and rogue stays open for testing a tank that is on nobody's side. Its world grew from 240 to 320 across purely to make room outside the flag rings for them; the rings themselves are where they were. ### Fixed - A zero-height obstacle is flat rather than four units tall. `obs.h || 4` read a height of 0 as a missing one in eleven places, so a `base` with `size w d 0` -- upstream's own `CustomBase` default, a pad painted on the ground that a tank drives onto and a shot flies across -- became a solid block that stopped shots and hid the ground under it. `getObstacleHeight` in the collision pair now asks whether the number is there rather than whether it is truthy, which is what `getColliderTopY` beside it has always done; the two disagreed until now. An obstacle whose map gave no `size` at all still falls back to 4. - A base drawn flat on the ground is coplanar with it, which is a coin toss per pixel. The shared base material takes a depth bias, which costs a base with real height nothing. ## [1.0.73] - 2026-09-07 ### Added - `SW` Shock Wave (#6, phase 10): firing destroys every tank inside an expanding sphere, through walls and roofs. A shot with no path -- it never leaves the tank that fired it and what travels is its radius, from `_shockInRadius` (`_tankLength` 6) out to `_shockOutRadius` 60 over `_shockAdLife` 0.2 of a shot's life. Nothing stops it, so it is the first shot in bzo that is more than one kill, and its hit test is the only one in the game that reads no collider at all -- a plain sphere to the tank's own position, which is upstream's "behind or in a building or even zoned". Your own wave cannot kill you; a teammate's can, which is what the flag's help text warns about. A shield holds for the whole wave rather than for one tick, as upstream's local `endShot` makes it. The reload is the world's own -- `ShockWaveStrategy` is the one shot strategy that never scales it -- so a wave is gone in 0.7s and the next still comes round on the full 3.5. Drawn as upstream's low-quality wave, a translucent team-coloured sphere fading 0.75 to 0.25, because its default one inverts the colour inside the sphere with `glLogicOp` and WebGL has no logic op; the shooter predicts it locally, it fades without an impact or a boom, and it is a circle of the current radius on the radar. ### Changed - The sound tables in `docs/audio.md` and `AGENTS.md` list every sample bzo actually plays. `laser.wav` was missing from both since phase 8 and `flap.wav` from `AGENTS.md` since Wings; `shock.wav` joins them. - `docs/flags-plan.md`'s running tally caught up with the code. It still called phases 7 and 13 unstarted and none of phase 4's view flags in, and counted fifteen superflags where bzo has twenty-three. - Known gap, written up in `AGENTS.md` under Shot timing: a shot variant's slot frees on the shot's life rather than on its reload, so for `L` Laser and `SW` Shock Wave -- the two flags whose life is not the reciprocal of their rate -- the server's slot check is looser than the client's fire gate. Honest play matches upstream either way; the fix is a slot expiry separate from the shot lifetime and is being taken on its own. ## [1.0.72] - 2026-09-07 ### Changed - A teleporter's border is resolved into its solid by the importer, so the world goes on the wire collision-ready and a teleporter's `w`/`d`/`h` mean what they mean on a box. BZW states the *opening* and leaves the frame to be derived, which is a trap for every reader downstream: three open-coded the growth and two got it wrong, including the debug outline whose whole job is to show the surface a tank stands on. `getShotTeleporterDims` is a reader rather than a calculator on both sides now, deriving only the portal opening inside the frame, and the renderer reads the same numbers -- so the drawn frame is the collided frame by construction rather than by two places agreeing to add the same border. Also picks up an upstream line bzo was missing: `finalize` takes `max(border * 0.5, size[0])` for the x extent, which differs from `size[0]` at any border but the default. - `maps/flagbuffet.bzw` has a 40x40 platform and a second teleporter on top of it, linked to the ground one both ways on both faces, so obstacle edges can be tested without a `JP` or `WG` flag in hand. It also asks for `-set _wingsJumpCount 3`. ### Added - `-set _wingsJumpCount <n>` in a map's `options` block sets how many flaps `WG` Wings carries, joining `_maxFlagGrabs` as a BZDB variable bzo reads. Zero is meaningful -- a wings tank that cannot flap -- so the floor is 0. ### Fixed - Landing on a teleporter lands on its frame, and driving off an obstacle edge works (#39). A teleporter's solid is the frame, which `Teleporter::finalize` grows from the stated size by the border, and those grown values *are* the obstacle's extents upstream. bzo took the stated height instead, so a tank landing on `maps/flagbuffet.bzw`'s portal stopped at 20.16 rather than the frame's real top of 21.28 -- sunk exactly one border into the top bar, standing on a footprint 2.24 units short of the frame at each end, and grazing the top of the active portal volume when it should be clearly above it. - The support snap no longer lifts a falling tank back onto a surface it has left, which is what made an edge unusable (#39). Upstream's collision resolve only ever *stops* downward motion -- `newVelocity[2] = 0.0f` against an upward normal -- and nothing in it raises a tank. bzo's snap accepted a surface up to `MAX_BUMP_HEIGHT` above the tank, right for driving up a kerb and wrong once a tank is past an edge: it dropped a hair, the knife-edge footprint test flickered back to true, and it was lifted to the roof and counted as having landed. At an edge that repeated every frame -- the buzz, the repeated landing ring, and the tank pinned on the lip, since being re-grounded re-zeroed its coasting speed so it could only escape by turning, exactly as #39 described being "skewered". While falling, a surface must now be at or below the tank to hold it; stepping up still works, because that happens with no downward velocity. - Driving off a ledge carries your speed. The fall recorded the frozen speed into `fallForwardSpeed` while the coasting branch read `jumpForwardSpeed`, which the landing branch zeroes -- so a tank left a ledge at a standstill. This also removed a client/server disagreement: the client told the server it was coasting at that speed via `vx`/`vz`, which is what the server extrapolates airborne motion from, while driving at zero itself. - The shot reload bars showed every slot reloading when one shot was fired (#36). Each bar was capped by a single global reload progress, so one shot turned all of them red and refilled them in lockstep, and the slot actually fired tracked the slower of its own shot's life and that global ramp -- which is the "moves more slowly and then skips ahead, and resets before reaching the end". Upstream reads a bar from the shot in its own slot and nothing else, an empty slot being full (`HUDRenderer.cxx:1988`); its one global gate, `jamTime`, reaches the "Reloaded in %.1f" *text* and never the bars. The bars are also sorted now, as upstream sorts them, since they tally how ready the slots are rather than naming them. Both the DOM and XR copies had the fault. - The shot reload bars no longer jump to the top left corner when you die (#37). The bar positions itself against the control box, which is hidden for the death camera -- and a `display: none` element still returns a rect, all zeros and truthy, so the guard passed and the zeros placed the canvas at the corner wearing whatever it last painted. The bar now hides with the control box, which is what upstream does by only drawing these while playing. - Driving through a teleporter no longer plays the spawn effect (#38). It fired `triggerSpawnEffectForTank`, which is three things at once -- the growth animation from 1% scale, the spawn burst, and the `pop` sound -- so an arriving tank grew out of the floor and read as a respawn, which is a different event with a different meaning. Upstream's tank teleport is `playWorldSound(SFX_TELEPORT, pos)` and nothing else: `addSpawnEffect` sits in the spawn handler one line above `setStatus(PlayerState::Alive)`, and the teleport effect upstream does have, `addShotTeleportEffect`, is for shots. The teleport sound is now the whole of it, on both the local prediction and the server echo. ## [1.0.71] - 2026-09-07 ### Added - Phase 13, per-viewer visibility: `ST` Stealth, `CL` Cloaking, `MQ` Masquerade and `SE` Seer (#6). Four flags that only ever disagree with each other, so they are read as a set -- `ST` hides a tank from the radar, `CL` hides it from the window, `MQ` makes it wear the viewer's own colours, and `SE` defeats all three. `ST` and `CL` are mirror images and carrying one does not buy the other: a stealthed tank is solid in the window, and a cloaked tank is still on the radar. - `CL` fades over `_flagEffectTime` rather than blinking out, easing on the same clock and rate rule as the dimension flags, and only a fully faded tank disappears -- part-way through it is part-way transparent and can be seen if you are looking. A hidden tank is hidden outright rather than drawn at alpha 0, so it costs no draws and casts no shadow, and its name label goes with it. The server-position ghost fades with the tank and is hidden when the tank is, so a debug build is not handed the position the flag hides. - `MQ` makes a tank wear the **viewer's own colour**, so it reads as friendly to that viewer and to nobody else. Upstream writes this as the viewer's *team* colour, which is the same thing there because its team mates share one; bzo shades team mates apart within a band, and the viewer's own colour is the faithful reading -- the team's base colour is one no real team mate wears, so a tank painted in it would be the only one with the exact base shade. It also means `MQ` works without teams in bzo, where upstream voids it: the viewer's colour is theirs alone either way. It folds into the one place a remote tank's colour is decided, in upstream's own order: colourblindness first and it wins outright, since a colourblind viewer has nothing left to be fooled about; then masquerade, defeated by `SE` and never applied for an observer; then the tank's own colour. Because a tank is built from its colour rather than tinted, and three flags can change it with no shared trigger, each tank's effective colour is compared against the one it was built from once a frame rather than hooking three events. - `SE` reaches two flags that were already in. `IB` Invisible Bullet goes back on the radar for a seer, so a seer is now the counter to an invisible shooter; and `ID` Identify can lock onto a stealthed or cloaked tank only with `SE`, while `B` Blindness refuses every target outright. - A Laser cannot hit a cloaked tank, which is the one rule in this phase the server owns. It is not a matter of what anybody can see -- a cloaked tank is genuinely immune to a beam -- so with bzo's server owning hits it has to be the server's answer. It is also what makes `CL` a good flag rather than a cosmetic one. bzo now implements twenty-two of upstream's forty-two superflags; **20 remain**, 11 good and 9 bad. ### Fixed - A tank could not jump off an obstacle above roughly 95fps. The support search accepts a surface up to `SUPPORT_SNAP_DOWN` 0.2 units below the tank, and a jump's first frame rises `jumpVelocity * dt` -- 0.32 units at 60fps but only 0.13 at 144 -- so on a high-refresh display the tank was snapped straight back onto the surface it had just left, the landing branch zeroed its velocity, and the jump was eaten. Frame-rate dependent, so it looked like a per-device problem. A rising tank now has no support at all, which is upstream's rule: it stops vertical motion against a surface only "if going down" (`LocalPlayer.cxx:637`). Most visible with `WG` Wings, where every flap off a roof was being swallowed. - A cloaked tank's server-position ghost stayed visible. The ghost's visibility was decided only when the debug-geometry toggle changed, so a tank that cloaked afterwards kept its ghost -- a full tank clone that writes depth, occluding the ground grid behind it and drawing the silhouette of a tank that is meant to be invisible. It is now evaluated every frame, since a cloak completes 0.64s after the flag event that started it and there is no event at the moment the tank should vanish, but written only when the answer changes. ## [1.0.70] - 2026-09-07 ### Added - `B` Blindness, `JM` Jamming and `CB` Colorblindness, three of phase 4's four bad flags (#6). All three are honoured entirely on the carrier's own client, which is where upstream honours them -- there is no packet and no server rule, because a modified client that ignored one would only be cheating itself out of a penalty it is carrying. The server's whole part is three rows in the flag table. - `B` blanks the view and keeps the radar, which is upstream's `SceneRenderer::setBlank`. One switch covers both surfaces: everything the game draws hangs off `worldGroup` and every HUD panel hangs off the camera, so hiding the former leaves the instruments alone on the flat canvas and in a headset alike. The sky goes black with it, so a blinded tank cannot read the time of day off the horizon. - `JM` replaces the radar with generated static, and carries upstream's cadence rather than an even flicker: `decay` is the chance of a good frame, starts at 0.01 so about one frame in a hundred breaks through, and a good frame sets it to 1 which guarantees a second before it halves away. So a jammed radar gives a readable burst every second or two. XR needed no path of its own -- the XR radar panel is textured from the DOM radar canvas, so the static is in the headset on the same frame. The static is translucent where upstream's noise is opaque, which is deliberate: upstream's radar owns a region of screen outside the 3D viewport, so covering it costs the view nothing, while bzo's radar floats over the 3D view. A jammed frame replaces the panel background rather than covering it, at the alpha that background would have had, so the jammed panel is exactly as heavy as a working one and hides no more of the world than the radar hides anyway. - `CB` paints every other tank, its shots and its radar blip in the rogue colour. bzo colours players individually rather than by team, but in team mode the server shades team mates apart within a band around the team colour, so that colour is exactly where the team is legible and replacing it is the faithful move. Your own tank keeps its colour, as upstream's does. `ID` Identify degrades with it, dropping to "Looking at a tank" rather than naming the callsign, since the name would give away the team the colour no longer does. The scoreboard is deliberately untouched, which is also upstream's choice. bzo now implements eighteen of upstream's forty-two superflags; **24 remain**, 15 good and 9 bad. `WA` Wide Angle is the last of phase 4 and is **blocked on an XR decision**: the headset owns the projection, so a field-of-view change has no XR implementation, and shipping it would make the same flag a real penalty in a browser and a no-op in a headset. `docs/flags-plan.md` records the three options. ## [1.0.69] - 2026-09-07 ### Added - Phase 7 of the flag plan: `T` Tiny, `N` Narrow and `O` Obesity, and the per-player tank size all three need (#6). A flag scales the tank's length and width and never its height, which is what `Player::updateFlagEffect` does, so a Tiny tank is short and stubby rather than small and an Obese one is wide rather than tall. `getTankDimensionScale` in the flags pair is the whole rule, with upstream's `_tinyFactor` 0.4, `_obeseFactor` 2.5 and Narrow's 0.001 width. `testOrigRectTank` and `pyramidIntersectsTank` now take that scale, so obstacle collision, the swept step and the support-surface test all honour it on both sides of the wire: a narrow tank fits sideways through a gap nothing else fits, and an obese one is stopped by a teleporter without a rule of its own, because the portal interior is already checked at full size. - The shot hit test follows upstream's two shapes rather than the drawn tank. `Player::getRadius` is `dimensionsScale[0] * _tankRadius` -- the length scale -- and upstream's own note says "the Obese, Tiny, and Thief flags adjust the radius, but Narrow does not", because Narrow only touches width. So every flag but `N` meets a sphere scaled by the length factor, and `N` meets an oriented box, whose half width is the **shell radius** rather than the tank's, in upstream's words: "width of box is shell radius so you can actually hit narrow tank head on". At the tank's real narrow width it would be unhittable head-on rather than hard to hit. - The drawn tank eases between sizes over `_flagEffectTime` 0.64s, keeping upstream's `dimensionsScale`/`Target`/`Rate` per tank and fixing the rate when the target changes, so the ease is linear and takes exactly that long however far it travels. Only the drawing eases: the collision and hit sizes are the target from the moment the flag changes hands, because bzo's server owns hits and a hitbox that disagreed with it for two thirds of a second would be worse than a tank that resizes faster than it looks like it should. The server-position ghost is scaled by the same factors -- it is a sibling of the tank, not a child, so it inherits nothing and a full-size ghost around a Tiny tank would misreport the one thing it is there to show. bzo now implements fifteen of upstream's forty-two superflags -- `US` `ID` `JP` `WG` `R` `NJ` `SH` `F` `MG` `L` `SB` `IB` `T` `N` `O` -- and twenty-seven remain, fifteen good and twelve bad. `docs/flags-plan.md` is the list. ### Changed - `maps/flagbuffet.bzw` sheds a bad flag after one kill, so its outer loop can be worked through without dying for each one. ## [1.0.68] - 2026-09-07 ### Added - `maps/flagbuffet.bzw`, from upstream: three of every flag in its own zone, laid out alphabetically clockwise from the north, good flags on the inner loop and bad on the outer. It is the map the rest of this release exists to read, and the quickest way to get a given flag into your hands to test it. bzo places 33 of the 42 flags it declares -- three each of the eleven superflags bzo implements that this world's game style also allows. - `-ms <count>` in a map's `options` block sets how many shots a tank may have in the air at once. Unlike the switches bzo already read out of that block this carries a value, and upstream parses a map's options where `-world` sits on the command line, so the map's number replaces `shotMaxActive` from `server.json` rather than only ever raising it. The reload time is re-derived from it, since each slot comes back after `_reloadTime / maxShots` and moving one without the other would leave a tank reloading at the wrong rate; a `shotReloadTime` pinned in the config still wins. `maps/flagbuffet.bzw` asks for `-ms 3`. - `-s <count>` and `+s <count>` in a map's `options` block set how many superflag slots the world holds, replacing `superFlags.count` from `server.json`. The count is optional and upstream turns anything unparseable *or zero* into 16, so `-s`, `-s 0` and `-s 16` all mean sixteen; `+s` differs only in keeping every slot filled at once, which bzo's insertion schedule already approximates, so both spellings read the same. `+f <abbrev>{count}` is still not read -- bzo has one pool and one slot count, with no per-type counts to pin flags into. - `-f <abbrev|good|bad>` in a map's `options` block takes a flag type, or a whole quality, out of the pool a slot draws from -- upstream's `flagDisallowed` table. Disallows accumulate and nothing puts one back, and they filter the pool next to the types the game style already forbids. - A `zone` block's `zoneflag <abbrev|good|bad> [count]` places flags, as upstream does. Each declared flag gets its own slot pinned to that type and bound to that zone, so it always respawns inside it -- upstream's `setRequiredFlag` plus the `#<flagId>` zone qualifier that `getFlagSpawnPoint` asks for first. The slots sit between the team flags and the `-s` tail, which is upstream's order, so a zone flag's index does not move when `-s` changes, and a required flag goes straight back into the world on reset rather than waiting out the insertion schedule. A type the game style forbids or bzo does not implement is skipped and named on load; `maps/flagbuffet.bzw` declares 42 types and gets 33 flags, three each of the eleven that survive both tests. Zones stay on the server: upstream ships them to clients only so `World::writeWorld` can write the map back out, and no gameplay on either side reads them. - `-srvmsg <text>` in a map's `options` block, a line the world says to each player as they join. It accumulates the way upstream's does -- every occurrence is another line, and one occurrence may carry several by writing a literal `\n` -- and goes out as ordinary server chat to the player who just arrived, which is where `bzfs.cxx:2507` sends it. The text is read off the raw line so its own spacing survives, since upstream treats a quoted argument as one token. Separate from `motd`, which is the entry dialog's label and has no upstream equivalent: upstream's own MOTD is a client-side fetch of a BZFlag project announcement and no game server has a say in it. `maps/flagbuffet.bzw` carries three lines explaining how its zones are laid out. - `-set _maxFlagGrabs <n>` in a map's `options` block sets how many pickups a superflag survives, replacing `maxFlagGrabs` from `server.json`. It is the only `-set` variable bzo reads; any other is named on load rather than skipped in silence. Only the server acts on it, as upstream does -- `FlagInfo.cxx:137` reads it on grab and the drop spends it -- but it rides `GAME_CONFIG` into the `init` payload so a client can see the rule it is playing under, which is what upstream gets for free by shipping every BZDB var. ### Fixed - A teleporter that gives no `size` or `border` gets upstream's defaults at parse time rather than in each reader, so no consumer can see an undefined dimension. A missing half extent is not a small teleporter: `testOrigRectRect` classifies a corner with `cx < -dx2 ? -1 : (cx > dx2 ? 1 : 0)`, both comparisons are false against NaN, and the corner lands inside the obstacle -- so one sizeless obstacle overlapped every tank everywhere in the world. - `findSupportSurface` no longer stands a tank on anything it can drive through, which `checkCollision` has always refused to be blocked by. The world border's visible wall is `driveThrough` and `WORLD_WALL_HEIGHT` tall, so a tank that jumped near it could rest on the one roof the two-collider border design says is not a surface at all. - `findSupportSurface` skips a collider missing a dimension instead of treating it as four units tall. The `obs.h || 4` fallback turned a malformed collider into a platform at y=4 spanning the whole map, which is where a tank on `maps/flagbuffet.bzw` ended up standing. ### Changed - The dev server's `server.json` now holds bzfs's own defaults for every gameplay switch a map can set -- no jumping, no ricochet, one shot slot, no superflags, and a bad flag shed only by dying, which are `CmdLineOptions`'s constructor values -- and the dev server's gameplay moved into `maps/hix.bzw`, where a bzfs mapper would put it. That map now carries `-j`, `+r`, `-ms 5`, `-s 200`, `-st 50`, `-sw 2` and `-sa`, each with a comment saying what it does, so switching maps switches the rules with them and the config no longer implies anything about how a world plays. `wingsJumpCount` and `wingsSlideTime` stay in the config at upstream's values, because both are BZDB variables reached only through `-set`, which bzo does not read. - `superFlags.allowed` is gone from the dev server's `server.json`. It listed exactly the twelve superflags bzo implements, which is what the field defaults to when absent, so the copy only stood to drift as flags are added. `example-server.json` keeps it, and bzo's two documented deviations from bzfs defaults, so a first start is playable before anyone edits a map. ### Fixed - A `teleporter` that gives no `size` or `border` now gets upstream's defaults from the `CustomGate` constructor -- half breadth `_teleportBreadth` 4.48 and height `2 * _teleportHeight` 20.16 -- rather than half of each. Every other map in `maps/` spells its teleporters out, so nothing had reached the default path until `maps/flagbuffet.bzw`, whose portal was drawn and collided against at half the depth and half the height bzfs gives it. The half width 0.56 and border 1.12 defaults already matched. ## [1.0.67] - 2026-09-07 ### Changed - Shot hits are tested against the segment a shot travelled rather than the point it landed on, which is what `SegmentedShotStrategy::checkHit` does upstream. A Rapid Fire shell covers 2.5 units in a step against a tank 4 units across, so a point sample could step past the edge of one. The sweep also runs before the obstacle test now, so a tank standing in front of a wall is hit before the wall is, and it takes the nearest tank rather than the last one it looked at. - The world border stops a shot only as high as the wall you can see. Upstream's border is one `WallObstacle` doing two jobs: an infinite half-space that stops a tank at any altitude, and a wall only `_wallHeight` tall to a bouncing shot, which flies over it rather than back into the arena (`makeSegments`, `ignoreHit`). bzo now says that with two colliders a side, each doing one of the two jobs and standing aside from the other with one of upstream's own flags. The barrier, a thousand units high -- taller than any map bzo has to hold -- is the tank collider and is `shootThrough`: upstream's wall as a tank meets it, a height-ignoring half-space with no roof. The visible wall, only `WORLD_WALL_HEIGHT` tall, is the shot collider and is `driveThrough`, so tanks are held by the barrier at the same inner edge and the wall's roof is not a surface any collision code has to reason about. `WORLD_WALL_HEIGHT` is upstream's `3.0 * _tankHeight`, and the renderer draws the wall to it, so what bounces a shot is exactly what a player can see. The drawn wall was 5 units and the collider 1000, which is why a ricochet could come back off thin air. - `drivethrough`, `shootthrough`, `passable` and `ricochet` are read per obstacle from a `.bzw`, as bare keywords, which is how upstream's `WorldFileObstacle::read` takes them. The shot tests skip a `shootThrough` obstacle, both copies of `checkCollision` skip a `driveThrough` one, and `traceShotStep` bounces an ordinary shot off a `ricochet` one. `maps/test.bzw` has a labelled box for each. - The map parser matches every keyword against the line's first token, without regard to case, as upstream matches with `strcasecmp` -- so `Position` is a position and `basey` is no longer read as a `base`. It also takes upstream's own `pos` and `rot` aliases and a pyramid's `flipz`, none of which it read before: a map written with the short spellings used to arrive at the origin, unrotated, with its flat-topped pyramids upright. Every map in `maps/` parses to byte-identical geometry either way. - A per-obstacle `ricochet`, upstream's `Obstacle::canRicochet`, bounces even an ordinary shot -- upstream's third source of a bounce after the world switch and the flag. Its own border walls are built with it off, which is why only a shot that ricochets of its own accord bounces off the border. - A shot's vertical hit test measures against `_tankHeight` 2.05 from the collision pair rather than a literal 2. ### Added - Five shot variants, the whole of the flag plan's phase 8 (#6): `F` Rapid Fire, `MG` Machine Gun, `L` Laser, `SB` Super Bullet and `IB` Invisible Bullet. A shot's velocity, rate, lifetime, obstacle rule, radar visibility and firing sound now come from the flag that fired it, resolved once by `getShotEffects` in the flags pair and carried on the projectile from then on -- so a shot keeps the rules it was fired under even after its shooter drops the flag. Rate needs nothing of the shot slots, because upstream declares Rapid Fire's and Machine Gun's lifetime as the reciprocal of their rate: a shot that lives a tenth as long gives its slot back ten times as fast. bzfs's own server gate agrees, scaling by `AdLife` and never reading `AdRate`, so only the client's interval between shots consults it. `L` Laser is a beam rather than a projectile. At `_laserAdVel` 1000 a shell crosses any bzo world inside one simulation step, so the server walks the whole path when the trigger is pulled -- through buildings, the ground, teleporters, the world edge and any tank in the way, one segment at a time, as upstream's `makeSegments` does -- resolves the hit there, and leaves the segments for the client to draw as upstream's untextured core-inside-a-glow beam. It reflects on a `+r` world, sounds its own `SFX_LASER`, and plays a ricochet at every bend. - `findShotSegmentImpact` in the collision pair answers "what does this segment hit first" for a segment of any length. `findShotImpact` bisects from the far end, so it only finds an obstacle the far end is inside -- fine for one 1.67-unit step of an ordinary shot, and useless for a laser, which crossed the world in one segment and went through every wall on the way. Each obstacle is asked for the interval where the segment crosses its oriented bounding box and the intervals are walked nearest first, so the exact test runs only where it can matter. - `docs/bzw.md`: what the `.bzw` import reads and what it ignores -- the coordinate conversion, every obstacle and `options` keyword bzo understands, and the notable absences, since a map using one of those still loads and plays with that part of it missing. ## [1.0.66] - 2026-09-07 ### Changed - Keyboard and mouse drive the tank together. With mouse steering on, each axis goes to the first source with something to say -- keys, then a stick, then the mouse box -- so a held drive key owns its own axis and the mouse keeps the other: steer with the mouse while holding `W`, or turn with `A`/`D` while the mouse sets the speed. Holding `W` and `S` together is a deliberate stop, so a held pair still owns its axis at zero. `M`, the settings row and Escape are now the only things that turn mouse steering on and off. Driving no longer touches the setting: upstream bumps between input devices on its own because its methods are exclusive and one has to be chosen (`allowInputChange`, `playing.cxx:821`), and with both driving at once there is nothing left to pick. Escape goes through the same toggle as the row, so the button, the menu and the saved preference cannot disagree. - Mouse steering is offered on what the machine can do rather than what its user agent says it is. A phone with a mouse paired to it steers as a desktop does, so the question goes to `(any-pointer: fine)` -- live, so plugging a mouse in lights the row up without a reload -- and a real mouse event outranks the query, which answers `false` in a headless browser on a machine with a mouse on the desk. VR closes the gate whatever the pointer says, since an immersive session has no cursor to read, and so do the on-screen controls, which steer instead while they are up. In each case the row goes dead rather than inert: it stays on the menu reading `Unavailable` and the focus walks past it, and the saved preference is left alone so it comes back when the context does. - Right click carries `identify` through the same held input as the on-screen button, the gamepad shoulders and the XR B button, so it works with a gamepad plugged in and a guided-missile lock will pick it up unchanged. bzo claims `contextmenu` to keep the browser's own menu off the battlefield, but only there: a right click in the chat box or the name field is still a paste. - A click that misses the on-screen controls does nothing instead of firing. Those buttons own the whole screen while they are up, so a fat-fingered miss between the joystick and the fire button is a miss, not a shot. Left click fires everywhere else, with mouse steering on or off, as it always has. - The on-screen controls remember being turned off. A phone is handed them at startup, which is a fair guess from a user agent and no answer at all to a player with a keyboard and a mouse plugged into one, who had to dismiss them every session. The `Enter` key also fires on a phone now, which the same user-agent test had been preventing. ### Fixed - The scoreboard keeps the flags players are carrying when somebody joins. Two surfaces draw the roster -- the flat list and the headset's panel -- and each had gathered its own inputs, so a repaint could arrive without the flag lookup and blank the column. A team score change is one such repaint and a join changes team counts, so every client lost every carried flag the moment anyone joined, until something else redrew the board. The rows are now built in one place that no caller can call with half the information, and the same rows are what the headset draws, in the same order -- it had been sorting by a third set of rules and drawing observers among the players. It also reads them once per change rather than rebuilding and re-sorting every frame. - The scoreboard drops a flag from the player who dropped it. `MsgDropFlag` names its owner by design, so the drop repainted the scoreboard while the flag was still on the tank, and the `flagUpdate` that makes it anonymous never repainted at all -- leaving a shaken bad flag beside the player's name, for every client, until some unrelated event happened to redraw it. - Entering or leaving VR tells the rest of the client. The event two listeners were waiting on was never dispatched, so anaglyph stayed on into a session that draws its own stereo pair, and leaving VR left the XR menu open and the entry dialog unoffered to a player who had never seen it. ## [1.0.65] - 2026-09-06 ### Added - `scripts/headless-client.mjs`, which joins the running server in a headless Chrome and reports every console error and uncaught exception, optionally with a screenshot or an expression evaluated in the page. Chrome speaks CDP over a WebSocket and `ws` is already a dependency, so this adds no browser automation library and nothing to install. It renders through SwiftShader, so it answers whether a change draws without throwing and what it looks like, never how fast it is -- `renderer.stats` in `server.log` is still where frame cost is read, from a real GPU. AGENTS.md now says which to reach for. - The entry dialog closes without joining. It has an `[X]` in its heading like every other dialog and a Cancel button beside OK, and both put back what the dialog was opened on top of. It had offered no way out that was not a join: OK joined, Default joined under a blank name, and there was nothing else -- which meant a player already in the game could not open it to look at the tank list without giving up the life they were in the middle of. - Nothing the entry dialog offers takes effect until OK. The name, the team and the tank are a draft: the carousel previews a tank without swapping the one in the world, storing the preference or telling the server, and Default resets the three fields rather than joining under them. OK applies what changed and only what changed -- the tank travels on its own, while a name or a team means a rejoin, because the server settles both at join time. Nothing changed means nothing sent, so opening the dialog and pressing OK no longer costs a rejoin, which resets the score. - The team selector is offered to a player already in the game. It had been greyed out for them and the dialog worked around it by pretending the player had left the moment it opened; the rejoin on OK is what changing a team costs, and it is now the only thing it costs. - Hiding the window pauses the tank, and showing it again resumes. This is the event upstream actually pauses on -- `Unmap`, the window being iconified (`playing.cxx:1246`) -- and `document.hidden` is the browser's word for it. A window that is merely unfocused is left alone, because it is still on screen and upstream does not pause for that either. Both this and the menus reconcile through one function rather than each toggling a pause of its own, so hiding the window with a menu open and showing it again leaves the tank paused: the menu is still up. It is the same code path that decides move, fire and jump are not reaching the tank. - Opening a menu pauses the tank, counting down in front of the menu that started it, and closing it resumes. Upstream pauses when its window is iconified and resumes when it comes back (`playing.cxx:1211`, `:1246`): a player who cannot see the game cannot answer for the tank standing in it, and a tank that cannot be answered for is a free kill. A browser tab has no unmap, but a menu covers the same screen, so bzo hangs the same behaviour on the menus -- every one of them, flat or in a headset, since they all arrive as the same input context, and since walking from Settings into Audio closes the first and opens the second. It is upstream's `pausedByUnmap` in full: the pause is remembered as the menu's, so closing the menu never resumes a pause the player took themselves, and the pause key does nothing while it is the menu holding the pause. - A pause countdown can be called off by pressing pause again, which is what `cmdPause` does with a second press (`clientCommands.cxx:451`). Closing a menu during those five seconds calls it off the same way. - Everyone is told when a player pauses or unpauses, as `MsgPause` tells them upstream (`playing.cxx:2446`). ### Changed - The help panel lists flags as Team, Good and Bad, in that order, with every bad flag's name in the bad-flag colour. Upstream splits the same three across pages of their own -- Good Flags then Bad Flags (`HelpMenu.cxx:416`, `:453`) -- and bzo's help is one scrolling panel, so the split is a heading and the bad flags go last: the thing you are looking one up to avoid should not be mixed in among the ones you want. The sections are built from the flag table's own quality, so a bad flag added later lands in the right one wearing the right colour without anyone remembering to put it there. - Left and right act on the focused menu row and no longer double as up and down. A choice row steps back and forward -- Camera and Radar Range had been cycling forward whichever way they were pushed, so walking back through three camera modes meant going forward twice -- an on/off row flips either way, a submenu opens on Right, and an action row takes neither. Rows that had nothing to adjust were swallowing the keys silently; now the press simply stops. Up and down are what move between rows, everywhere. The XR panel had the same fall-through and a worse version of it: sideways on a shared row moved the selection instead of changing anything, because the panel only knew how to adjust its own rows. Both menus now ask one function what left and right do to a settings row, so they cannot drift apart. Back stays its own control -- Escape, B or grip -- rather than being hung on Left, which would have meant two different things depending on the row. The help panel keeps its exception: it is read rather than operated, so up and down scroll it and left and right still move focus, which is how a controller reaches its close button. - The tank carousel in the entry dialog is a choice row like the team selector above it, so left and right walk the tanks from anywhere inside it rather than only from whichever arrow the focus is sitting on. - A flag this client knows to be bad is drawn in orange wherever it appears -- standing in the world, riding over the head of the tank carrying it, and beside a name on either scoreboard. Upstream has no colour for this because upstream never tells you which superflag you are looking at; bzo remembers every flag it has seen, and the one thing worth knowing at a glance is whether picking it up is a mistake. The colour comes from the same remembered identity the flag labels already use, so a flag whose identity is still hidden stays white, as every superflag is upstream. - The bad-flag colour is orange rather than the warning red the pickup alert wears. Red is a team here, and a red flag over a red tank on a red scoreboard row says nothing; no team is orange. The pickup alert keeps upstream's red, where nothing else on screen is coloured and the message is momentary. - The radar draws a base in its team's own radar colour, shaded once and kept. It had a third hardcoded table of four teams, clamped so a fifth would have come out red, and it mixed and formatted a fresh colour string for every obstacle on every repaint. `Team::getRadarColor` is what upstream's radar draws a base in (`RadarRenderer.cxx:1186`) and bzo already had that table; the shaded result is now built once per team and reused. - Every base on the map is two draw calls, whatever teams hold them, instead of six each. A base needed six materials because the texture repeat lives on the texture and its four sides tile by size while its top and bottom take the picture once; and it needed a texture of its own per team because the team colour was painted into the pixels. Both are now data on the vertices -- the repeat baked into the UVs, as the boxes and pyramids already do it, and the team tint as a vertex colour that Three multiplies the shared picture by. So the bases merge into one mesh alongside them, and two textures serve the whole map rather than six per base. - A base takes its tint from its team's own colour rather than from a table of four. The tint is the team colour mixed six parts to four with white, which is what the old table held for red, green and purple; blue moves a shade towards bzo's own blue, which is not the pure blue the table assumed. Nothing here counts teams any more, so a fifth team with a base costs no new code, no new texture and no extra draw call. - Obstacles sitting on the ground are built without their bottom face, as upstream builds them: `BoxSceneNodeGenerator.cxx:66` ("Don't generate the bottom polygon if on the ground (or lower)"), `BaseSceneNodeGenerator.cxx:74`, and `PyramidSceneNodeGenerator.cxx:109`, which keeps a pyramid's base only when it is raised or stood on its point. The faces were back-facing and already thrown away by the rasteriser, so this is geometry rather than frame time -- except for a base's, which was a fully transparent quad drawn in the blended pass rather than a face left out of the mesh. - A paused tank wears upstream's sphere: black at half alpha, still, and a tank and a half wide (`Player.cxx:1004`, `1.5 * tankRadius`). bzo had drawn a spinning cyan wireframe ball less than half that size, which read as an effect a flag had granted rather than as a tank nobody is driving -- and bzo now has a Shield flag for that reading to be confused with. It is named for what upstream calls it too, in the client and in the renderer. - Pausing takes five seconds instead of two, upstream's own countdown, and it is counted down on the alert HUD a second at a time rather than announced once. The alert is slot 1 for a second at a time, which is the slot and the duration upstream gives every pause message. - The alert HUD draws over dialogs. It had sat under them, which was harmless while every alert belonged to the game behind the menu -- but opening a menu is now itself what starts a pause countdown, and a countdown that a menu can hide is a countdown nobody reads. It already drew over the scoreboard and the radar on the same grounds: an alert lasts a few seconds, and reading it matters more than what it briefly covers. - A pause is refused from the air or from inside a building, with upstream's own wording, and refused again if the tank has driven somewhere it may not pause from before the countdown runs out. Both checks exist for the same reason upstream gives: a pause gives up the team flag, so it may only be taken somewhere the tank could still legally stand having lost it. - Pausing gives up the team flag and nothing else. Whatever is left goes fifteen seconds later, which is `_pauseDropTime` -- long enough to answer the door, short enough that a superflag cannot be parked out of the game indefinitely. bzo had been dropping everything the instant the pause took hold. - A pause belongs to the life it was taken in: dying during the countdown calls it off, and a tank that respawns comes back playing. - The local messages are upstream's: `Paused` and `Resumed`. The old text told the player to press P, which is not how a pause the menu took is undone. ### Fixed - A browser that has never been here is asked for a name. `isDefaultPlayerName` answered no for the empty string a first visit starts with, so the entry dialog never opened and the client joined under an empty name for the server to replace with `Player n` -- against the intent stated one line above it, "only send join if there is a saved name of the player's own choosing". Having no name is the clearest case of not having chosen one. - A paused tank stops where it paused on everyone else's screen. Both ends dead-reckoned it forward at whatever speed it was carrying when the pause landed, so a tank that paused while rolling drifted away from where the server had it -- with the sphere drawn around it going along. `getDeadReckoning` (`Player.cxx:1127`) does not move a paused tank at all, and now neither does bzo, on the client that draws it or the server that answers for it. - A paused tank cannot shoot. `invalidPlayerAction` kicks a paused player who fires (`bzfs.cxx:4352`) and bzo checked nothing at all, so an invulnerable tank could keep firing -- the one thing a pause must never buy. Reported as a shot rejection rather than as malformed, because the pause takes hold on the server and a shot already in flight from the client crosses it. - The entry dialog no longer clears a pause on its way out. It freezes the tank while the player picks a name, which is not a pause and never was one, but it was writing the same flag the server's pause state is read from. - The entry dialog no longer tells the server the player left. It sent a `leaveGame` message the server has never had a handler for and hid the tank on the local screen alone, so the tank everyone else could see stood there and could be shot while its owner was reading a menu. The tank stays where it is and pauses, which is what every other menu now does, and the pause is lifted when the dialog closes. ## [1.0.64] - 2026-09-06 ### Added - `?xrRate=90`, the cadence asked of the XR runtime. Asked for once the session is live and never awaited: on a Quest, called before the renderer had handed the session its layer, `updateTargetFrameRate` returned a promise that never settled, and a startup waiting on it left the VR button refusing every press with "already in progress". A measurement may report late or not at all, but it may not decide whether the session starts, so there is a timeout behind it and silence produces a line of its own. A Quest 2 offers 60/72/80/90/120 and, asked for nothing, reports `frameRate=0` -- it has picked one and will not say which. Whatever it picked is the rate its compositor runs at, and a compositor at 90 or 120 reprojects a client delivering 30fps three or four times over, on the same chip, in time that lands in `outside` where nothing can see it. bzo now asks for 72, the headset's own rate, and logs what came back; the knob is there because which rate is best here is a measurement rather than a guess. - `session.frameRate` and `session.supportedFrameRates` on the `[WebXR]` line when a session starts, with whether `updateTargetFrameRate` exists. Both are optional in the spec, so both may read unknown. Without them `outside` cannot be read at all: a frame spent waiting for the display and a frame spent working look identical from inside the callback, and which one it is decides whether there is anything left to win. - `timerQuery` in `renderer.capabilities`: whether `EXT_disjoint_timer_query_webgl2` is there to time the frame on the GPU with. The browser on the device is the only authority on that, and it is one line to ask it. - `lighting` on the `renderer.stats` line. It decides whether the scene has seven lights in it or none, which is a different compiled shader for every material in the world. - `debugLabels`, `debugGeometry` and `debugHud` on the `renderer.stats` line, and a `debug` bucket in `draws` beside them. All three change what is in the scene -- labels are a sprite over every obstacle, and a ghost is a deep clone of a whole tank -- so a sample that does not say whether they were on cannot be compared with the one beside it. The ghost is tagged explicitly because a clone carries the original's userData: without that it counted as a tank, and turning debug geometry on silently doubled what the tanks appeared to cost. - `draws` on the `renderer.stats` line: what the frame is made of, bucketed by tank, shadow, world, cloud, scenery, teleporter, base, flag, effect, debug and HUD. `calls` is a total, and a total has now been read wrong twice from the outside -- once as flags that turned out to be a tenth of what was claimed, once as obstacles that were already collated across the world into per-material fragments. Every mesh in the visible scene is charged to its nearest tagged ancestor and counted as the draws it would submit, walked once per sample rather than once per frame. Frustum culling is not modelled, so this is what the scene offers rather than what Three accepted; for the projected shadows, which are never culled, the two are the same number. - `?xrScale=0.7`, the pixels a headset draws into. `renderScale` cannot ask for them: it works through `setPixelRatio`, and the framebuffer a session draws into belongs to the headset, so a Quest 2 reported 2880x1584 whatever the page asked for -- the knob took effect on the flat page and was then handed back the headset's own resolution on entering the session. This one is `setFramebufferScaleFactor`, a fraction of the resolution the runtime recommends, set before any session starts because it is read when the session builds its framebuffer. It is the only way to tell fill apart from everything else on a headset, which is where the measurements now point. - A `renderer.stats` line every twenty seconds while an XR session is running, marked `reason=xrSession`. A headset is the machine that cannot open the debug HUD to read its own counters, and the single line ten seconds into a map was a race against getting into the session at all: enter late and the sample describes the flat page instead, which looks perfectly reasonable and answers a different question. A series also means a mode can be read on its middle sample rather than on wherever the player happened to be standing for the only one. - `/test.html`, the measurement knobs as a list of links. They are URL parameters and nothing else -- never persisted, never in the game UI -- which on a desktop is a query string to type and on a headset is a query string nobody is going to type. Point a client at the page, press a mode, press Back, press another. Every knob bzo has is on it, with what each one drops and what to read afterwards; AGENTS.md now says a new knob goes there too, because a knob the device cannot reach is a knob that device is never measured with. ### Changed - A tank casts one shadow instead of one per part. A tank arrives from the loader as seventeen objects and each of them cast its own, which the draw breakdown measured at `shadow:75` -- three quarters of everything the map itself costs -- and none of them cull, because a shadow's flattening matrix can under-estimate its bound and a shadow that pops out is worse than one always submitted. The shadow never needed the parts: it is a flat silhouette, the parts do not move against each other, and what does move on a tank moves by scrolling a texture rather than by turning a wheel. The silhouette is merged once per model in the tank's own space and shared by every tank wearing it, so a full field costs one geometry and one draw each. - The DOM HUD is not painted during an XR session. The chat window, the altitude tape, the altimeter, the heading tape and the shot status are all covered by XR panels of their own in a headset, and each of them measured its element with `getBoundingClientRect` before deciding whether to repaint -- a layout flush per panel per frame for something nobody is looking at, measured at 0.41ms of a 13.75ms budget. It comes back on the first frame after the session ends: none of it is state, each paints from what the game currently is, and the one piece that defers -- the chat window -- keeps its dirty flag until somebody draws it. - The clouds are one draw each instead of one per puff. A cloud is five to twelve overlapping spheres, and each was a mesh with a material of its own: on a world of fifteen clouds that is around 125 draws, which the new `draws` breakdown measured as the second largest thing in a headset frame -- behind the tanks and ahead of everything the map is made of, and constant however little is happening. The puffs are baked into one geometry per cloud, sharing one white for the whole sky. Merged per cloud rather than all into one because a cloud is what moves: each drifts at its own speed, so a mesh each keeps the animation a translation and asks nothing of the geometry per frame, and leaves the clouds sorted against each other. What is given up is sorting between the puffs of a single cloud, which are the same white at the same opacity. A map change now lets the geometry go, too; the old per-puff meshes were never disposed. - In XR, identify moved to the thumbstick press and the settings menu moved to B. A stick sits under a thumb that is already steering, so it is the control that gets pressed by accident -- and what sits behind it has to be the thing that costs nothing when it fires unasked. Identify picks a roaming target and does nothing at all for a tank yet; the menu takes over the view. B now carries the whole menu rather than only opening it -- open from the game, back out of a submenu, close from the top -- because a press that both opened the menu and was read as the back inside one would have opened and closed it in the same frame. Grip is still the other way back out of a submenu. - The lights a shot, an explosion or a jump jet asks for are drawn from a pool of seven instead of being added to the scene and taken out again. Upstream caps them at `GL_MAX_LIGHTS` less the one it reserves for the sun or the moon and keeps the nearest of what is left (`SceneRenderer.cxx:1275`, `OpenGLLight::calculateImportance`), which bzo never did; bzo needs the cap for a second reason besides. Three keys its shader program cache on how many lights are in the scene, so every light arriving or leaving recompiled every material in the world -- a firefight on a Quest 2 went from 16 programs to 66 in two minutes, eleven of them compiled inside a single second, and each compile is a stall on that driver. The pool is built once, never hidden and never removed, because Three does not count a light it cannot see: hiding one costs the same recompile as deleting it, while a light dimmed to nothing costs a uniform and changes no program. Each frame the effects asking for light are ranked the way upstream ranks them -- anything further off than its own reach is dropped, the rest by how near the eye is, measured in the world group's frame so a session's heading is not applied twice -- and the first seven are handed the pool. The ground receivers follow the pool rather than the requests, since a receiver stands for light that actually landed. - The matrix walk moved out of the projected shadow pass and into the frame, where every mode pays it. It was marked as a phase inside the pass, so `?shadows=0` did not merely skip the shadows: it skipped the accounting, and the same walk then happened inside Three's own render and landed in `draw` instead. Two modes reported different-shaped frames for the same work, which is the one thing a measurement knob must not do. - Every flag in the world is drawn in nine draw calls instead of two each. A flag was a pair of meshes with materials and a pole geometry of its own, so a world running 200 superflag slots submitted hundreds of draws for a few thousand triangles -- and in a headset, where the scene is submitted once per eye, twice that. What makes the batch possible is upstream's own state: `FlagSceneNode::notifyStyleChange` draws an ordinary flag opaque, with no blending and `setAlphaFunc(GL_GEQUAL, 0.9)` cutting the cloth's shape out of the texture, and only reaches for blending when the flag's colour has alpha below one -- which is a flag warping in or out. An opaque flag needs no sorting against its neighbours, so all of them ride in `InstancedMesh`es: one per wave set for the cloth, since a set is already a shared geometry, and one for every pole in the world, which are identical. A fading flag drops out of the batch and gets a blended mesh of its own for the second it is fading, because an instanced mesh has one material for all of it. bzo had been drawing every flag the way upstream draws only the fading ones, so the cloth now has the hard edge upstream gives it rather than a blended one. ### Removed - The slide traces and the stuck report the client sent while resolving its own motion. Every frame a tank spent against a wall wrote a chat entry, laid out the chat window and sent a packet, which on a headset is DOM work and a panel repaint in the middle of the frame -- and a tank wedged somewhere sent twenty a second for as long as it was wedged. What they were for, the client and the server disagreeing about where a tank is, is what the anti-cheat findings now report from the side that can compare the two. The debug face markers stay: they draw in the world when debug geometry is on and cost nothing when it is off. ### Fixed - A shot stopped lighting the ground fifty units from the player, whichever way they were facing, and did it with light slots to spare. The pool ranks what it cannot all fit by `OpenGLLight::calculateImportance`, and that drops a light for being far away *only when it is behind the viewer*: `sphereCull` is cleared the moment the light is in front of the front plane, and the distance test below it never runs. A light in front is ranked by distance, never disqualified by it -- a shot lighting the ground a hundred units ahead is a shot the player can see lighting the ground. The fifty units that do still apply are the light's own reach, which is `maxDist` upstream and the point light's `distance` here. - A bad flag now reads red on both scoreboards instead of white. `NJ` is the only one bzo has so far, and it looked like any other superflag on the roster -- the same white as Shield or Wings, when it is the opposite of them. The red is the one upstream already uses for the same fact, the warning colour its pickup alert wears (`playing.cxx:1455`), so a penalty keeps the colour it was announced in. Only the scoreboards changed: the flag in the world and on the radar keeps `FlagType::getColor`, which is white for every superflag. - The death animation threw a headset player across the map. What the camera follows is the body -- a piece of debris with a velocity of its own, tumbling and bouncing away from where the tank died -- and in a session there is no camera to move, so the world was swung after it instead: the player was thrown along with the thing being thrown. A session now leaves the world exactly where first person put it, which is the spot the tank was standing on, and the explosion happens around the player. Upstream has no death camera at all; the chase is bzo's own and it stays on a monitor, where a camera is a camera rather than a head. - Double-digit scores ran off the side of the XR scoreboard. The score column started at a fixed offset from the panel's right edge and drew left to right, so it was sized for one digit and grew outwards past the border with two; the name column was cut to a character count that could not know how wide the score beside it was. Both columns are laid out in pixels now, as the other canvas HUDs are: the score is right-aligned against the panel's margin so it grows towards the middle, and the name is trimmed to whatever room is left after the score and any carried flag. A short name also gets more of the row than the old fourteen-character cut allowed. - A tank killed in an XR session left its shadow behind, and the shadow held still against the view while the world turned under it. A dead tank is hidden by its group and not by the meshes under it, so the shadow pass -- which was only given tanks that were visible -- was never told to put those shadows away, and a shadow keeps the last matrix it was handed: it is written straight into `matrixWorld`, where nothing re-derives it from the world group. On a desktop that is a dark patch left where the tank died; in a session the world group carries the player's own heading, so it reads as a shadow stuck to the head. The pass now sees every tank and takes the group's visibility as the answer for everything below it, so the shadow goes with the tank and comes back when it respawns. A player who leaves takes their shadows with them too, which is the same bug on a different trigger. - A tank fell through roofs when frames ran long, which on a headset means whenever anything else was happening. One frame is one step of tank motion, and the step was judged only by where it ended: land more than a metre below a roof in a single step -- 19 units of fall per second is what a jump lands at, so about 20fps -- and the roof was never reported, the step became a collision with the wall underneath it, and the tank slid out through the side of the building. A thin deck was worse: clear it body and all in one step and nothing was asked about it at all. Upstream solves this without a smaller timestep, and bzo now does the same two things it does. `BoxBuilding::inMovingBox` widens the *vertical* extent of the occupant test to the span the step covered, leaving the footprint at the end of the step -- so `checkCollision` takes the height the step began at and tests the whole sweep. `Obstacle::getHitNormal` decides a landing by which plane the step crossed rather than by how near the top it started -- so a step that begins at or above a flat top and ends below it has landed on it, however far it fell, and where several were crossed at once it is the highest that catches the tank. Both are comparisons on numbers already to hand, in the loop that already runs, so a frame that was fast enough before is unchanged. Pyramids are deliberately left out of the sweep, as `PyramidBuilding::inMovingBox` leaves them out: a slope's cross-section depends on the height it is taken at, so there is no one rectangle to sweep. Anti-cheat was never involved -- `enforce` refused the move that ended inside the building and rubber-banded the player, which hid this; `warning` let the client's own mistake stand, which is the mode doing its job. - The XR scoreboard drew every name in the same near-white, so the one thing the flat scoreboard's rows tell you at a glance -- which tank on the field is that -- was missing in a headset, and the shading that tells team mates apart with it. Each row is now written in the player's own colour, name and score alike, exactly as `updateScoreboard` colours a row; the carried flag keeps the flag's colour, which it already had. Your own row was the near-white's one exception and is now marked the way the flat scoreboard marks it, bold over a green band, since colour cannot single it out once every row is coloured. ## [1.0.63] - 2026-09-05 ### Added - `SH` Shield, the first of the flags that change what a hit does rather than what a shot is (#6). Being shot costs you the flag instead of your tank: the shot ends where it struck, the flag is thrown as if you had dropped it, and nobody scores -- no kill, no death, no shake win. The flag it throws goes `_shieldFlight` 2.7 times the normal altitude, which is sqrt(2.7) ~ 1.64 times the flight, so there is time to drive back under it; that is the whole of what the extra altitude is for, and it applies to every way a Shield leaves a tank because upstream tests the type in `FlagInfo::dropFlag` and nothing else. Only a shot is absorbed -- upstream tests the reason as well as the flag (`playing.cxx:3919`), so a capture or a self-destruct still kills a shielded tank. No new message: the shot's end draws the impact and the drop the client already handles plays the sound and says what was lost, which is the feedback upstream gives. ### Changed - The XR HUD has a margin, the way the DOM HUD has `--hud-edge`. A session has no viewport edge to measure from, so the panels are bounded in degrees off the gaze axis instead -- 28 out, 24 up, 24 down -- and every panel is held inside that box by the one function that places them. The chat sat with its tab strip 33 degrees below the gaze axis, which on a headset is under the lens rather than in front of it; it now stops at 24. The radar came in from 31 degrees out and 29 up, at a size that keeps its inner corner where it was rather than pushing it onto the crosshair, and the scoreboard, the shot slots and the notices all came in with it. Three constants are the whole layout, so a headset that wants a tighter box needs one number. - The XR chat panel shows the six messages the desktop window shows, in the colours the desktop window gives them, instead of three in one colour. The palette moved into CSS custom properties that the canvas reads back, so there is one list rather than a copy that drifts. The tab strip is gone: nothing in a session points at that panel and the tabs are bound to the keyboard, so five labels you cannot press were spending a third of the panel. What replaces them is a caption naming the tab you are reading, and, when something has arrived on another one, a count of the tabs holding it. ### Fixed - Third person in XR showed whatever first person last showed and then stayed there while the tank drove away. The camera belongs to the headset in a session, so the two XR views that work move the *world* instead; the third-person branch only wrote to the camera, which three.js overwrites from the head pose every frame, and never touched the world group -- so it kept the offset first person had left on it. It now places the world from the chase point every frame, taking the tank's heading as first person does, so forward means the same thing in both. The desktop branch also resets the world group, which is what left the view stuck when leaving a session in third person. The chase offset is one pair of constants shared by both paths. - Flag poles leaned and swung with your head in XR. A flag is billboarded, and upstream billboards it against the whole view rotation (`ViewFrustum::executeBillboard`) -- which keeps the pole upright on a monitor only because a desktop camera has no roll and little pitch. The pole was vertical *on the screen*, not in the world, so in a headset it lay over as you looked around, and it turned about the eye rather than about its own pole, so a flag span in place when you merely looked away from it. It now yaws towards the viewer and does nothing else: the cloth faces you, the pole stays where the world put it. The turn is also measured in the world group's own space, since in a session that group carries the player's heading and reading the camera's orientation straight off applied that heading twice; and it is taken after the camera is placed rather than with the rest of the flag work, so it is not a frame behind. On a desktop this is visible only when looking up or down at a flag, where the pole now stays vertical. - One shot could hit two tanks. The server's hit sweep tests a projectile against every player and deleted it on the first hit without leaving the sweep, so two overlapping tanks both died to it -- and with Shield the second would have died to a shot the first had already absorbed. The sweep stops at the tank the shot is spent on. ## [1.0.62] - 2026-09-05 ### Fixed - Anti-cheat `warning` mode still blocked. Only the two drift checks respected the mode; the collision check, the jump check, the shot checks and the flag grab, shake and capture checks refused the packet in every mode, and the acceleration limit quietly replaced the client's reported speed with the server's. So a mode whose whole purpose is to leave the game alone and write the disagreement down was rubber-banding honest players, swallowing their shots and eating their jumps -- and each of those divergences went on to generate drift warnings of its own, burying the one line that said what actually went wrong. Every rejection now routes through one `reportCheat` function that counts the finding, logs it with `REFUSED` or `ALLOWED`, and returns whether the caller must refuse; in `warning` the answer is no. The collision check also ran in `disabled` mode, which is now the one mode that does not look. - A move or shot packet carrying a non-finite number was accepted. `NaN` fails every threshold comparison, so the drift checks passed it through and the stored position, rotation or velocity became `NaN` along with every extrapolation made from it afterwards. Malformed packets are refused in all three modes and logged as `MALFORMED`; they are not anti-cheat findings, because nothing about them is a judgement call. ### Changed - A dropped flag launches from where the tank actually was, dead-reckoned like every other position the server judges. It read the stored position, which between packets is out of date by construction -- and worst in the air, which is where flags are most often dropped: the client sends nothing between takeoff and landing, since the heartbeat is gated on being on the ground and vertical velocity is extrapolated rather than reported. A tank shot down at the top of a jump therefore dropped its flag from the height it took off at, so the flag launched from the floor and landed under the tank instead of arcing down from it. Past a whole jump of silence the server stops believing its own extrapolation and uses the stored position, since a connection that stopped sending still carries whatever speed it had. Upstream takes this position from the client instead (`sendDropFlag` packs `myTank->getPosition()`, and `bzfs.cxx:3745` clamps it only to the world bounds); bzo reaches the same place without trusting the client for it, and the wire format already matched -- the drop broadcast carries the launch, the landing and the flight, as `flag.pack` does. - The acceleration check allows the fastest rate any stick position could have produced, instead of picking a rate from the speed the client reported. The client chooses deceleration by the *desired* input (`updateMovement`), which is not in the packet -- what is in the packet is the speed the tank actually reached, and a tank coasting to a stop reports non-zero speeds all the way down. So the server read every key release as an acceleration and refused it at the slower rate, and since both decelerations are faster than their accelerations (2.5 against 1.8, 4.0 against 3.0), letting go of a key was always a finding. Two thirds of the warnings in a six-minute session were this, landing exactly on the deceleration rate: 0.25 in 0.100s, 0.47 in 0.117s. - Move packets carry `sdt`, the interval the client took to get from the last packet's speeds to these ones, and the acceleration check uses it. `dt` was already sent but measures one frame -- the window `fs` and `rs` were sampled over, not the window they changed over -- and the server was using its own gap between arrivals, which is the send interval plus network jitter. When jitter shortened it an honest ramp looked impossible; that was most of the remaining warnings, all within 15% of the limit. `sdt` is a client-asserted input to a cheat check, so it may only widen the window and only by 0.25s. - Anti-cheat warnings are counted per kind -- collision, paused, speed, shot, jump, flag -- and the five-minute summary and the disconnect line list whichever kinds actually fired instead of naming two of them. ## [1.0.61] - 2026-09-05 ### Changed - The second team on a map is founded across from the first. Upstream picks evenly among the teams that tie (`autoTeamSelect`, `bzfs.cxx:1902`), and with one team down and three empty, two of the three are neighbours -- so the team across the map came up only a third of the time and two players testing landed beside each other more often than not. That one choice is weighted by the squared distance between the two teams' bases, which on a map with its bases at the compass points makes the far team exactly twice as likely as either neighbour: 50/25/25. Which team is across from which comes from the bases rather than from the team numbering, since that is a property of the map. Only that choice is weighted -- once two teams are populated there is no single team across the map, a player joining teams that already exist is picked as upstream picks, and a map with no bases falls back to the even pick. ## [1.0.60] - 2026-09-05 ### Fixed - The on-screen fire and flag buttons do nothing on anything but a phone. The condition deciding whether to read the virtual input was written three different ways and only the driving one accounted for the controls being turned on deliberately, so turning them on gave a jump button that worked beside two that did not. All three share one predicate now. - Toggling Virtual Controls, and Wireframe, leaves the settings row reading its old value. Those buttons stop their own click propagating, so the menu never sees it and never re-reads the row; both now tell it, as Mouse Control always has. - The altitude tape is not attached to the targeting box. Its offset was a fixed 40px against a width in `4vmin`, so the two only agreed at one screen size: on a 1920x1080 desktop the tape sat against the box, and on a 857x411 phone a 24px gap opened. Both terms are the box's own unit now. - The chat tabs fall off the top of the chat window on a short screen. The window packs to its bottom edge and hides its overflow, and the message area's six-line minimum meant it would not shrink, so the tabs were pushed out. Six lines is a ceiling now rather than a floor: a tall screen still fills to six, a short one shows fewer and keeps its tabs. ### Added - The scoreboard's title carries the colour your tank is drawn in and the flag you are holding, in the shape upstream writes them -- the name, then "/", then the flag's abbreviation, with the colour changing at the slash and no space between (`ScoreboardRenderer::drawPlayerScore`). Both were otherwise readable only by finding your own row, and the colour is worth having to hand now that team mates are shaded apart rather than sharing one team colour. Clicking the flag opens Settings as clicking the name does. - Settings closes from its heading like every other dialog. It had only a Close row at the foot of its menu, and the operator panel centred its close button beside the heading while help and audio put theirs against the right edge; the four share one layout now. Settings keeps its Close row as well, because in XR the menu is rendered as rows and the heading's button is not one of them. ### Changed - The HUD takes its sizes from the scale upstream uses for its own -- `min(width/256, height/192)`, which bzo already had for the targeting box -- rather than from width breakpoints. A phone held in landscape is 857x411: wide enough to miss every rule meant for a small screen and short enough that none of them would have been wrong. Sizes are clamped, because upstream is scaling geometry and this is scaling text; the ceilings are what a desktop already had, so nothing changes there, and between the bounds the HUD follows the screen continuously. - The HUD ignores `env(safe-area-inset-*)`. Honouring the camera cutout spends an edge of a screen that has none to spare, and a hole over the corner of a panel is the better trade -- the panel keeps its size and the covered part is still touchable. The margins that remain share one value, so the radar, the scoreboard, the debug HUD and the chat cannot drift apart. - Settings is only as wide as its widest row. It shared one width with the dialogs that hold prose or a form, and a column of short label-and-value rows stretched to 820px was mostly gap. - The settings rows centre at every width. A narrow-screen rule let the value column shrink to its content and handed the label all the slack, which with a right-aligned label pushes the pair to one side and piles the empty space on the other. ## [1.0.59] - 2026-09-05 ### Fixed - Shadows point straight down and never move when Dynamic Lighting is off. `setWorldTime` returned early on that setting, so the sun kept the `(0, 1, 0)` a `DirectionalLight` is born with -- a permanent noon -- and the sun and moon were removed from the sky as well. Neither belongs to that setting: what fragment uniforms constrain is a scene full of point lights, not one directional sun, and the sun is what the sky, the day cycle and the direction of every projected shadow are read off. The lights a shot, an explosion and a jump jet add are still gated by it, which is the cost the capability is about. (Shipped in this release but missing from its notes.) ### Added - Team mates are shaded apart within their team's colour. Upstream gives every tank on a team the one colour (`Team::getTankColor`, `Team.cxx:30`) and has nothing per player; bzo already gave every player a colour of their own outside team mode, and the radar already drew blips in whatever the server assigned, so this narrows that same picker to a band around the team colour with only team mates to stay clear of. Two red tanks are told apart at a range where the labels are unreadable, and a red tank is still unmistakably red: the hue spread is bounded by the closest pair of team colours, red and purple, and `test:team-mode` asserts that against the colour table rather than trusting a comment. Nothing is rebalanced when a player leaves -- the gap they free is the one the next joiner is most likely to take, and a tank that changed colour mid-match would undo the only thing the shade is for. ### Changed - A box is two draw calls, a pyramid two, and every one of them in the world is now drawn from a single merged mesh. Upstream collates the faces of one mesh that share a material into a `MeshFragSceneNode` (`MeshSceneNodeGenerator.cxx:206`); bzo collates the same way but across obstacles rather than within one, because a draw call costs far more here than it does there -- 24 to 30 microseconds on the low-power client this is measured against, with the GPU idle. On `hix.bzw` the 58 boxes and 60 pyramids were 236 draws plus 118 more for their shadows; they are now two meshes, four draws and two shadows. Bases and teleporters stay out, which is upstream's rule as much as ours: one carries a team colour and a hidden face, the other animates. - A flag's arrival warp is built the first time it warps. A world of 200 flags carried 1600 discs that drew nothing but still had a matrix composed for each of them, twice a frame. - Boundary walls no longer build the face that points away from the arena, so backing a tank against the border in third person shows the tank rather than the back of a wall. Nothing is made transparent: the camera meets the inward face from behind and back-face culling removes it. From every position a player can occupy the wall is as solid as it was. - The debug HUD stops below the radar rather than scrolling underneath it. Together the obstacle and flag changes take the low-power reference client from 34 to 57 frames per second at a fixed spawn, with draw calls down from 523 to 283 and the frame from 27.5ms to 16.7ms. ## [1.0.58] - 2026-09-04 ### Changed - A box is two draw calls rather than six, and a teleporter four rather than fourteen. `BoxGeometry` gives every face its own material slot, and the tiling rode on each face's own texture, so one box asked for six draws and six copies of the same two images. The tiling is baked into the geometry's UVs instead, which leaves nothing size-dependent in the material and lets every box in the world share one wall material and one cap material; a teleporter's fourteen quads accumulate per material and become one mesh each. On `hix.bzw` that takes the boxes, boundary walls and teleporters from 484 draw calls to 156, which is the cost that matters on a client that runs out of one core long before it runs out of GPU. - The help panel scrolls on the arrow keys rather than moving focus between its links. Cycling a document's handful of links left most of the text unreachable, and in XR there is no Page Up or Page Down to reach the rest with instead. Arrows step a line, the Page keys a screen, Home and End the ends. Left and right still move focus, which is how a controller reaches the close button without a Tab key. - Scrollbars follow the theme rather than the platform's grey, in the debug HUD, the help and every other panel that scrolls. ## [1.0.57] - 2026-09-04 ### Added - Where a frame's time goes is split further: the forced world-tree walk the shadow pass does (`matrix`), the geometry rebuilt on the CPU before anything is submitted (`worldfx`), the draw itself, and `outside` -- everything between one frame callback ending and the next starting, which is the wait for the GPU and the display, the browser painting the HUD's DOM, socket handlers and collection. The phases and `outside` are the whole frame, so a client that is slow with every phase small is saying that nothing bzo controls is the work to cut. - `renderer.stats` carries `drawbuf`, the drawing buffer the sample was measured at; `fastest`, the shortest frame of the window, which bounds the display's refresh interval from below because no web API reports it -- a client pinned at 30fps by a 30Hz panel and one pinned there by its own cost are otherwise identical in the log; and `programsWindow`, the low-high program count, because Three keys its program cache on the light count and a count that moves during play is a recompile rather than a bigger scene. - Two measurement knobs, `?renderScale=` and `?antialias=`, to tell the cost of the pixels bzo draws from the cost of the surface the browser presents on hardware nobody here owns. URL only, never persisted, never in the UI, and recorded in the log so a sample says which configuration produced it. - Clients reload when the client code changes. The server hashes `public/` and Three's build directory by content at boot and sends that id when a client joins; a page keeps the id it booted with and reloads on any that differs. A tab reconnects across a server restart on purpose, so without this it can go on running code from before the edit for as long as it stays open. Editing `server.json` or a map does not change the id, and those restarts stay silent. ### Changed - The debug grid draws before the flags rather than after. Flag cloth, poles and warp discs blend without writing depth, so a grid line standing behind one could not be depth-rejected and drew straight through it. - The service worker's cache is keyed to that same build id rather than to the release version, and a cache miss revalidates rather than trusting the seven day `max-age` an asset carries. An edited texture used to survive the reload meant to replace it. Over HTTP/2 a full revalidation is a few tens of kilobytes of 304s rather than megabytes of assets that did not change. ### Removed - The CSS2D label pass. Nothing in bzo ever created a `CSS2DObject` -- every label is a sprite -- so it walked the whole scene graph three times a frame to produce nothing. - `public/test.html`, a minimal XR page that has served its purpose. `public/audio/README.md` moved to `docs/audio.md`: everything under `public/` is sent to clients, and documentation does not need to be. ## [1.0.56] - 2026-09-04 Getting rid of a bad flag, and No Jumping, are part of #6. ### Added - The shake timeout, BZFlag's `-st`: how long a bad flag sticks before it falls off on its own. `flagShakeTimeout` in `server.json` and `-st <seconds>` in a map's `options` block both reach it, the larger of the two wins as a bzfs switch behaves, and it defaults to off as upstream has it -- with no timeout a bad flag is carried until it kills you. Values are clamped to 0.1s..300s and rounded to the tenth of a second upstream sends on the wire, so the client and the server count the same number down. - The client owns the countdown and asks for the drop when it runs out, as `LocalPlayer::doUpdate` does. The server records when it saw the grab and puts the request through `canShakeFlag` before it agrees, because a modified client would otherwise shed a bad flag the moment it took one; a refusal is logged as `[ANTICHEAT:...] SHAKE REJECTED`. The countdown re-asks rather than latching at zero, so an honest request that arrives a fraction ahead of the server's clock still lands. - The carried flag is named on HUD alert slot 2 for three seconds, in the warning colour when it is one you cannot put down, which is upstream's own slot and rule (`playing.cxx:1455`). A bad flag with a timeout running holds the slot and shows the time left to a tenth, which is what upstream's status line does. Both reach the XR panel, because the alert slots are shared. Pressing the drop control on a sticky flag says why it does nothing instead of sending a request the server will refuse. - Shake wins, BZFlag's `-sw`: a count of kills, clamped 1..20, that sheds the bad flag you are carrying. `flagShakeWins` in `server.json` and `-sw <kills>` in a map's `options` block both reach it, off by default as upstream has it. Upstream counts this down on the client and asks for the drop; bzo's server is what decides a kill happened, so it counts and drops and the client is simply told. A win counts and only a win -- never a loss, never a teamkill -- which is upstream's rule too. - Antidote flags, BZFlag's `-sa`: while you carry a bad flag a yellow flag stands somewhere in the world, and driving onto it sheds what you have. The spot is upstream's -- a random point inside a square centred on the world, half the world wide on a CTF map and the world less a base width otherwise, re-rolled while a tank would not fit there. It is drawn as a flag, on the radar in yellow over everything else, and as a yellow marker on the heading tape, which are upstream's own three. - The antidote's spot is picked by the server rather than by the client, which is where bzo departs from upstream. Upstream can leave it on the client because its bzfs accepts any drop request; bzo's refuses a sticky one, so a client-placed antidote would mean accepting every sticky drop on a server with `-sa` on and would undo the shake timeout's validation. It reaches its owner alone as `antidoteFlag`, and driving onto it is detected off position updates the way Identify's sweep is. - The No Jumping flag, `NJ`. Sticky and bad: a tank carrying it cannot leave a surface, whatever the world says. It is the other end of the jumping switch from `JP`, so exactly one of the two is ever in the flag pool -- `JP` forbidden while jumping is on and `NJ` while it is off, as `CmdLineOptions.cxx` has it. The altitude tape stays up under it, which is upstream's rule too. ### Changed - Picking up a bad flag says how this world lets you put it down -- a timeout, a number of kills, the antidote, or none of them, in which case it stays until it kills you. The drop control says the same thing rather than doing nothing. - The heading tape's flag markers no longer give up on a world with no teams, because the antidote marker does not need one. - A flag's endurance is read off the flag table through `getFlagEndurance` rather than derived from its quality, which is what `FlagInfo::addFlag` does. The two agree for every flag upstream declares; asking the table means they cannot drift. ## [1.0.55] - 2026-09-04 Ricochet is part of #6. ### Added - A ricochet switch, BZFlag's `+r`. `ricochet` in `server.json`, `+r` in a map's `options` block, and a checkbox on the Operator panel all reach it, and it defaults to off as upstream has it. A map may turn it on and nothing turns it back off, which is how a bzfs switch behaves. It reaches the client in the `init` config and again in `serverConfigUpdate`, so an operator can change the rules of a running game and everybody's shots change with them. - The Ricochet flag, `R`. Shots bounce off walls until their lifetime runs out; a bounce costs range and nothing else. With the server switch on, the server forbids the flag outright and says so in the log, as `CmdLineOptions.cxx` does -- a flag that grants what every shot already does looks like it does something and does not -- and the help panel says which of the two rules the world is playing by. - Shots reflect about real surface normals, on the client and on the server, out of one shared `traceShotStep` in the `collision` pair. It mirrors `SegmentedShotStrategy::makeSegments(Reflect)` and `ShotStrategy::reflect`, including the refraction branch upstream keeps for a normal that faces the wrong way. Buildings use `Obstacle::get3DNormal`'s rule, pyramids put a vertical component into a shot that was fired flat, the world border bounces because bzo models it as four boxes, and the ground is a surface of its own as it is upstream. A teleporter frame still stops a bouncing shot; see AGENTS.md. - A ricocheted shot can kill the tank that fired it, which is the flag's own help text. `LocalPlayer::checkHit` tests a player's own shots like anyone else's. Killing yourself that way is a loss and nothing else, as self-destruct is. - `SFX_RICOCHET`, with upstream's own `ricochet.wav`, and `StdRicoEffect` -- the flared cone thrown out of the surface at the point the shot bounced. - `shotBegin` carries the flag that fired the shot, which is what upstream's `FiringInfo` does. Ricochet is the first thing bzo reads off it; every shot variant still to come reads the same field. ### Changed - The obstacle test for a shot runs before the out-of-bounds test, so the world border is a wall a shot can bounce off rather than an edge it falls past. ### Fixed - A client that reconnected alongside others could keep showing them as `Player n` for the rest of the session. The roster in `init` is a snapshot, and it was applied only after the models, textures and audio it names had loaded -- a window seconds wide, during which the other clients were joining and their real names arrived and were applied. Replaying the snapshot afterwards put the placeholders back, and nothing corrected it, because movement packets carry no name. Roster messages that land in that window are now held and replayed over the snapshot in the order they came. - A connection is no longer a player on anyone else's scoreboard until it joins. bzfs's `sendPlayerUpdate` returns early unless the player `isPlaying()`, so a socket in limbo before `MsgEnter` is in nobody's roster; bzo was broadcasting one on connect, under the `Player n` name it had not yet replaced. It is not announced when it leaves either, and a tank chosen in the entry dialog before joining travels with the join instead of being broadcast on its own. - `queryPlayers`, BZFlag's `MsgQueryPlayers`. The client asks once on entering a map and the server answers with the whole roster, so a fully populated scoreboard no longer depends on every incremental message having arrived. Anyone the answer does not name is removed, so it reconciles in both directions. ## [1.0.54] - 2026-09-03 Part of #6. ### Added - A jumping switch, BZFlag's `-j`. `jumping` in `server.json` and `-j` in a map's `options` block both reach it, and it defaults to on, because every bzo tank could jump before there was a switch. A map's `-j` can still turn it back on where the config turned it off, which is how a bzfs switch behaves -- it never turns anything off. It reaches the client in the `init` config. - The Jumping flag, `JP`. On a world where jumping is off it is the only way an ordinary tank leaves the ground; on one where it is on, the server forbids it outright and says so in the log, as `CmdLineOptions.cxx` does -- a flag that grants what every tank already has looks like it does something and does not. - The Wings flag, `WG`. It takes off whatever the world says about jumping, and it is the one flag that drives and steers in the air: every other tank keeps the velocity it took off with. - Wings' four BZDB variables, as `wingsJumpCount`, `wingsJumpVelocity`, `wingsGravity` and `wingsSlideTime` in `server.json`. All four are `Locked` upstream -- the server sets them and the client obeys -- so they travel in the `init` config with the rest of the world's physics, and bzo reads them from its config the way it already reads `tankSpeed` and `gravity`. The count is what decides whether Wings flies or only steers: a surface refills it every tick and the take-off spends one, so at BZFlag's stock 1 there is one jump and no flaps left. Leave `wingsJumpVelocity` and `wingsGravity` out to get a wings jump identical to an ordinary one, which is what upstream's aliased defaults give you; `wingsSlideTime` above zero puts momentum in the air. - The server re-asks whether a tank may jump on every jump it sees, and logs a refusal as `[ANTICHEAT:...] JUMP REJECTED`. bzfs does not check jumping at all; without this a modified client would simply fly on a world that forbids it. - A flap is BZFlag's `SFX_FLAP` rather than `SFX_JUMP`, with upstream's own `flap.wav`. Upstream announces it to the other clients through `PlayerState::WingsSound`; bzo already knows who is carrying what, so the flag answers and nothing is added to the packet. - The altitude tape follows BZFlag's own rule and is up only where there is altitude to read: a world that allows jumping, or `JP` in hand. ### Changed - The jump control is one press, one jump. BZFlag re-sets its jump request at the operating system's key-repeat rate, which with `_wingsJumpCount` above 1 dumps every flap in a fraction of a second; bzo has no key-repeat event and samples a held boolean once a frame, so it takes the rising edge instead. A coasting tank does not sample the sticks, so landing re-arms the control and holding jump still bounces a tank down a building. - Flag messages use BZFlag's own names in both directions. `MsgGrabFlag` is one message upstream, sent by the client to ask and by the server to say what it decided, so bzo's `flagGrabbed`, `flagDropped` and `flagCaptured` are now `grabFlag`, `dropFlag` and `captureFlag` -- the name upstream's minus the redundant `Msg`, and the direction says which copy is which. ## [1.0.53] - 2026-09-03 ### Fixed - The voice volume test assigned to `globalThis.navigator`, which Node 21 turned into a getter with no setter. It worked on the pinned Node 18 and threw on 24, and since CI's main lint job runs on 24, it failed the whole v1.0.52 release. It now installs the stub with `Object.defineProperty`, which works whichever Node owns the name. - `scripts/test-server-name.mjs` no longer names real deployment hosts. It is testing how a hostname is shortened, not a particular site, and it runs on whatever machine somebody checked the repo out on, so it uses reserved example names instead. ### Security - `qs` is lifted to 6.16.0 through an `overrides` entry, clearing two moderate advisories that reach bzo through express. express 4.22.2 is the last 4.x and pins `body-parser` to `qs ~6.15.1`, so no 4.x release fixes this and express 5 is a breaking change; the override is a semver-minor bump inside express's own tree. `npm audit` now reports no vulnerabilities at all, dev included. ## [1.0.52] - 2026-09-03 Closes #29. ### Added - Game, voice, and microphone levels in Settings, on the renamed Audio Settings screen. Each is a slider from Off to 100%, usable with the mouse, with Left/Right on a focused row, and with an XR controller thumbstick on the XR Audio screen, and each is remembered between sessions. The scale is BZFlag's: an integer 0..10 with upstream's squared curve, `volumeAtten = 0.02 * level * level` in `src/bzflag/sound.cxx`, scaled so the top of the range is unity rather than upstream's 2.0, because Web Audio has no mixer stage to clip into. - Microphone level is a gain node between capture and the track sent to peers, since `getUserMedia` has no volume control of its own. It runs in the renderer's existing AudioContext rather than opening a second one. - Three voice channels, chosen in Audio Settings and on the XR Audio screen and remembered between sessions. **All** is every player on the server, however far away. **Nearby** is only players within earshot, whatever team they are on. **Team** is your own team, however far away -- and rogue and observer count as teams, which is what upstream does, since bzfs's team message dispatch sends to every player whose `isTeam` matches with no exception for either. A channel is a room: two players are peers only when they are in the same one, so the rule reads the same from both ends of a connection. It lives in the `voice-channels` client/server pair, and the server is what enforces it -- withholding the roster and the signalling is the only lever there is, because the media never passes through the server. - WebRTC peer activity is visible at last. The voice manager always tracked its peers and always offered them through callbacks; nothing listened, so a voice link that never came up, or quietly died, left no trace. Roster joins and departures, connection and ICE state changes, and remote audio attaching now reach the console, and the debug chat tab and server log when the debug HUD is on. The debug HUD also carries a row per peer: connection state, ICE state, and whether audio is flowing. ### Changed - The Voice dialog is now Audio Settings, and holds the three levels above the existing voice channel, microphone input, and browser audio-processing controls. The XR Voice screen is likewise XR Audio. - Observers report where they are. The roaming camera sends a position and a heading every five seconds -- the same `MAX_UPDATE_INTERVAL` heartbeat a driving tank already uses -- with every velocity zero, so there is nothing for either end to dead reckon and no prediction to correct. Upstream has the same idea in `sendObserverHeartbeat`, at a default of 30 seconds, which is coarse enough to list an observer and too coarse to place its voice. The server takes the position as sent: an observer has no collision, no shots and no score, so there is no state a lie could corrupt. It is relayed to the other clients as an ordinary `pm`, because voice is peer to peer -- each client decides for itself how loud a peer is and where it stands, so it has to be able to locate an observer too. - Observers use voice on the same terms as everybody else. They could already text chat, so the microphone ban was the odd one out. Whether an observer may chat at all belongs in a server option alongside the text-chat equivalent, not in a rule the client and server each hardcode. ## [1.0.51] - 2026-09-03 Part of #6. ### Added - The Identify flag, `ID`. While you carry it, the nearest flag lying on the ground within 50 units names itself -- "Closest Flag: Useless" on the HUD for five seconds and in the chat log -- and it says so once per flag rather than once per frame. It is entirely server-side and passive, as it is in BZFlag: the flag has no key, and it tells only its carrier, so the flag it names is still anonymous to everybody else. It is nothing to do with bzo's existing `identify` on `I` and right click, which picks the tank in your sights. - The client remembers which flags it has identified. bzfs reveals a superflag's type only while somebody is holding it, so a flag picked up and dropped goes anonymous again on the wire even though you watched it happen -- the client now keeps what it learned, whether from Identify or from anyone's grab. It forgets a slot when that flag leaves the world, because the next flag to arrive there is a fresh roll. - With the debug labels on, every flag whose identity this client knows carries its abbreviation over it -- `ID`, `US`, or `B*` for the blue team flag. A flag nobody has identified carries nothing, which is what makes the label worth having. - The help panel documents every flag bzo implements, with BZFlag's own descriptions, split into team flags and superflags and each named in the flag's own colour. It is generated from the shared flag table, so it cannot promise a flag the server will not hand out or miss one it will. ### Changed - `superFlags.allowed` now defaults to every superflag in the shared flag table rather than to a hardcoded list. The table carries only the flags bzo implements, so the default cannot put a flag in the world that does nothing. ## [1.0.50] - 2026-09-02 Closes #33. ### Added - Kill and death notices on the HUD, in BZFlag's shape: large centred text at the top of the screen for a few seconds, red when it is about you. "Got shot by <player>" and "Tank Self Destructed" are upstream's own wording; the matching "You killed <player>" is new here. It shows in VR as well as on the desktop HUD, and deliberately sits over the scoreboard and radar -- it is gone in four seconds and reading it matters more. - Observer mode. Joining as an Observer no longer parks an invulnerable, immobile tank on the field: an observer now has no tank at all and flies the map instead, starting from a spawn point facing the way a tank would. Drive and turn work exactly as they do in a tank, from the keyboard, the touch stick, a gamepad, or either VR controller, with Jump and Drop Flag climbing and descending. The camera rests at the eye height of a tank on the ground and never goes below it, and it looks level, like a tank does -- climbing carries what you are looking at up with you. Observers can already chat, and the radar follows the camera. Nothing draws the observer's own tank -- not the mesh, not its debug ghost. - Observer views: Free, Track (the camera holds still and turns to keep a tank in frame), Follow (a chase camera behind it), Driving with (through its own eyes), and Flag, which watches a team flag where the map has them. Fire, `C`, the Camera button and the Settings row all walk the same list -- in each view the leader first, then every player, then on to the next view -- replacing the first/third/overview modes that do nothing for an Observer. A status line at the top names the view and who is being watched, on the desktop HUD and in VR alike. - `identify`, on `I`, right-click, either VR B button, either gamepad shoulder, and a new touch button. It picks out whichever tank is centred in your sights -- BZFlag's targeting cone, about 17.5 degrees -- and in the Free view an Observer starts watching it, answering "Looking at <player>" on the HUD as BZFlag does. The other views are already pointed at someone, so it says so there instead. It also sets your nemesis, so `,` messages the player you just looked at; previously only a kill could set that. It will lock a guided missile once those exist. - Click a scoreboard row to watch that player, and click the marked row again to go back to following the leader. A status line names the view and who is being watched. ### Fixed - The service worker no longer serves files from another version's cache. Both lookups used the global `caches.match()`, which searches every cache present on the device -- including one left behind by a previous worker -- so a stale copy could be served even though the server, the response headers and a hand-run fetch all looked correct. Lookups are now scoped to the running version's own cache, and older caches are dropped when a new worker installs rather than only when it activates, since without `skipWaiting` a worker can wait indefinitely behind an installed app that never closes. - The touch control buttons no longer run off the top of the screen. They were laid out on a fixed 80px button and a 90px pitch, which needs more height than a phone held in landscape has, so the Drop Flag button sat stranded near the top corner and anything above it was off-screen entirely. Button size and spacing now scale with the viewport, and every button's position derives from one pitch so the margins stay equal. ### Changed - Observers now sort last on the scoreboard, as they do in BZFlag: they score for nobody, so ranking them among the players said something untrue. - In VR, Jump is the grip alone and the B button is now `identify`. The thumbstick press still opens the menu on either controller, so every action is still reachable one-handed. - The debug HUD moved from `I` to the backtick, which is unbound in upstream BZFlag and is the console key by convention elsewhere. `I` and Right Mouse now carry BZFlag's `identify`, as they do upstream. - Switching teams now drops a carried flag, as every other way of leaving play already did. It keys off the team actually changing, so changing only your tank keeps the flag. - A carried team flag is named on both scoreboards by its colour alone -- `Red` rather than `Red Team`. The label already carries that team's colour, so the word said nothing. ## [1.0.49] - 2026-09-01 Part of #6. ### Added - Flags, in BZFlag's shape. A world now carries 16 superflag slots -- upstream's `-s` default count, all Useless for now -- and each one arrives through the same animation BZFlag uses: hovering at `_flagAltitude` inside a stack of coloured warp discs, fading in, then falling to the ground. Drive over one to pick it up, and it rides on top of your tank with its cloth rippling on BZFlag's two out-of-phase waves. - `Space` drops the carried flag, which is upstream's `drop` binding and the key bzo has been holding unbound for it. In VR the primary face button (A) drops, so firing is now the trigger alone; a gamepad drops with X/Square, and touch play gets a Drop button above Jump. - A dropped flag flies upstream's parabola to whatever surface is under the tank. On its fourth pickup, or anywhere but the ground, it rises out of the world instead and a fresh flag appears elsewhere on BZFlag's halflife schedule -- so a superflag dropped on a roof leaves rather than falling off it. Dying, pausing, self-destructing or disconnecting all give the flag up where you stood. - The radar draws flags as BZFlag does: a cross a flag radius across for each one on the ground, and a larger cross on your own blip while you are carrying. - The server hides which superflag is which until somebody picks it up, exactly as bzfs does -- so an unidentified flag on the ground tells you nothing but where it is. - `superFlags` in `server.json` sets how many slots a world has and which flag types may fill them. - Team flags and capture the flag. A team-mode map with bases now gives every colour team a flag on its base, in the team's own colour. Carry an enemy flag home, or your own onto their base, and it is a capture: everyone on the losing team blows up and respawns on their base, and the team score moves. Capturing your own flag wins nobody anything and costs your team the same loss, which is what the "Don't capture your own flag!!!" alert is for. - In capture the flag every spawn is on your own team's base, as BZFlag spawns them. - A team flag behaves like BZFlag's rather than like a superflag: it never expires, it appears at its base instead of flying in, it lands on buildings, and it will not come to rest on another team's base. It leaves the world when its team empties and returns when the team's first player arrives -- or, if an enemy carried it off first, after `teamFlagTimeout` seconds. - BZFlag's flag alerts and their sounds: "Flag Alert!!!" when an enemy takes your team's flag, "Team Grab!!!" when a team mate takes somebody else's, and the capture-won, capture-lost and own-goal sounds. - `teamFlagTimeout` in `server.json` sets how long an abandoned team flag survives. - The heading tape above the targeting box carries a marker pointing at your own team's flag, as BZFlag's does -- a diamond where its bearing falls on the tape, or an arrow pinned to the edge it is past. It keeps pointing while an enemy carries your flag off, which is when it matters most. - Both scoreboards name the flag a player is carrying, after their callsign and in the flag's colour, as BZFlag's does: a team flag in full, a superflag by its abbreviation. - BZFlag's `-fb` is supported: superflags may spawn on and come to rest on buildings. A map's `options` block turns it on, as it does in bzfs, or `flagsOnBuildings` in `server.json` does it server-wide. `maps/hix.bzw` now sets it, so its roofs and walkways carry flags. ### Changed - The radar draws its layers in the right order. The dark panel used to be painted *over* the flags and shots already drawn under it, and the obstacles on top of that, so a flag standing on a base was buried under the base and a shot passing over a building was hidden by it. Panel first now, then the world border and compass, then obstacles, then shots, tanks and flags -- gameplay last, as BZFlag orders it. - Radar obstacles are painted lowest surface first, so where two overlap the higher one wins. The ring of supports around a hix base no longer looks like it is sitting on top of the base. - Flags on the radar use BZFlag's radar colour table rather than its tank colours. Upstream keeps the two apart on purpose -- red, green and purple are lifted so a team flag reads against a dark panel instead of sinking into it. Radar depth fading also matches upstream now, which stops at 0.35 for objects and 0.5 for obstacles rather than fading almost out. ## [1.0.48] - 2026-09-01 ### Added - The debug HUD and the `renderer.stats` log line break the frame down by phase -- XR, HUD, input, shadows, world simulation, radar, render -- in milliseconds per frame averaged over a second. One saturated core is the budget on the machines that matter, and the split between simulation, HUD painting and draw submission is what decides which cost is worth attacking. ### Changed - Projected shadows are placed by a matrix rather than by moving vertices, as `BackgroundRenderer::drawGroundShadows` does upstream. A shadow now shares its caster's geometry and costs one matrix multiply per frame, where it used to re-transform every vertex on the CPU, re-upload a buffer, and walk the whole world's matrices once per shadow. Measured on a Jetson Orin Nano, where one CPU core is the budget: the shadow phase fell from 13.7ms to 0.74ms per frame, and the client went from 36 frames a second to its refresh rate. - Nothing gates the shadow update on the light having moved any more. That check made the cost rise as the frame rate fell -- below about 5fps the sun moved far enough every frame to rebuild every obstacle shadow, which held the frame rate down, which kept it rebuilding. A client could sit at 1-4fps until something else happened to lift it out. ## [1.0.47] - 2026-09-01 Closes #30. ### Added - README lists the two public test servers: `bz.rikers.org` for development and `orin-bzo.rikers.org` for the nightly `linux/arm64` image. - Shots, impacts and explosions cast a pool of light on the ground again, drawn as BZFlag draws it: an additive fan under each dynamic light whose falloff is computed per vertex from the light's own attenuation, dimmed in daylight (`BackgroundRenderer::drawGroundReceivers`). It reaches 19 units where the point light alone faded out inside three, and costs forty triangles rather than a per-fragment light. - The launch `renderer.capabilities` line names the GL driver that answered. A frame rate means something entirely different when the string says llvmpipe or SwiftShader than when it names a GPU, and it is the one thing needed to read every other measurement against the machine that produced it. - The debug HUD reports what the last frame cost: draw calls, triangles, shader programs, and the GPU textures and geometries currently allocated. The same figures go to `server.log` as `renderer.stats` a few seconds into every map, and again when the debug HUD is closed, so the phones and headsets nobody opens that panel on still report their numbers. ### Changed - Shot, impact, explosion and jump-jet lights are as bright as BZFlag's. Every one of them derives from upstream's shared attenuation and its own colour scale, so a shot lights a wall it passes out to tens of units instead of fading out inside three, and the ground receiver stays in the same proportion to the real light that upstream has it in. - The ground is drawn with the same diffuse material as every other surface, rather than the only metalness/roughness shader in the scene, and only its front faces are drawn. It is the largest thing on screen, so it was the most expensive shader over the most fragments; upstream lights it diffuse-only. - The projected shadow darkening pass covers the ground a shadow can actually reach -- the world border plus the longest shadow the lowest accepted light can cast -- instead of the full ground, which reaches ten times the world in every direction. It is also frustum culled now that it is bounded. - Obstacle, wall, and base textures share one GPU upload per image instead of one per face. A map's world geometry now holds a handful of textures rather than several hundred copies of the same few pictures, and a team base tints its image once per team rather than once per face. - The XR HUD overlays stop repainting their canvases when no XR session is running. Outside a headset they were redrawing the chat, scoreboard, shot slot, and settings panels every frame for nothing. ### Removed - Unreachable code: `RenderManager.updateSunLighting`, `getLabelRenderer`, `getTankModel` and `createCelestialBodies`; `getInputContext` and `hideHelpPanel`; `subscribeToXRState` along with the subscriber set and publish call sites no listener could ever reach; the `createWallTexture` and `createObstacleTexture` aliases; and two frame counters that existed only to feed commented-out logging. ### Fixed - Sprite labels no longer leak a GPU texture every time they are relabelled. With debug geometry on, the packet motion gizmo relabels itself on every movement packet, so the leak grew for as long as the tank was driven -- a session could reach a thousand abandoned textures. A label now keeps one canvas for the life of its sprite and repaints it only when the text changes. - A shot impact no longer re-uploads its 512x512 explosion sheet on every frame of the effect. Walking the atlas moves the sampler, which reaches the shader as a uniform. - The sun and moon are visible again. They were placed at two thirds of the ground's extent -- 6.7 times the world size -- which put them beyond the far plane the mountains size, so they were clipped at every hour of the day, and would have been a few pixels across even in range. Both now sit at twice the world size and are sized by the angle they should subtend, taking those two figures from `makeCelestialLists` upstream while keeping bzo's own Minecraft-clock arc, and they draw before the world so the mountains stand in front of a low sun. ## [1.0.46] - 2026-09-01 Housekeeping. Nothing in this release changes how the game plays or looks. ### Changed - Shared client/server modules carry short names: `collision`, `motion`, `teams`, `shots`, and `headset`, alongside `capabilities` and `settings` on the client. Their test scripts and `npm run test:*` targets follow. - The four XR HUD overlays -- radar, chat, shot slots, scoreboard -- are built by one helper instead of each repeating the same canvas, texture, and camera-parented plane setup. - HUD toggle buttons declare what they read, write, and persist rather than each wiring its own click handler and label logic. A button whose capability gate is closed goes dead the same way for all of them. - Tank treads and wheels are assembled by one builder per part rather than mirrored left and right blocks, and the tinted BZFlag textures share one creator. - Random obstacle generation places boxes and pyramids through one routine. A procedurally generated map still follows the same rules, but will not come out identical to one from the previous release. ## [1.0.45] - 2026-09-01 ### Added - A Send button in the chat panel. It starts chat entry, and pressing it again sends the message and hands the keyboard back to the game, as Enter does. ### Changed - The chat panel no longer swallows the mouse. Only its own controls take the pointer, so a click over the transcript fires the tank -- which is where mouse control puts the cursor to drive backwards. While chat entry is active the panel takes the pointer back, and clicks stop firing. - Chat entry now ends only on Enter, Escape, or the Send button. A click on the battlefield leaves it typing rather than quietly dropping out. - Clicking the chat panel no longer starts chat entry. The Send button opens it, and until it does a click over the panel -- the input included -- fires the tank. - Mouse steering now follows BZFlag's targeting box exactly. The inner box is a dead zone where the tank holds still, the outer box edge is full turn and full speed, and each axis clamps on its own, so pushing the cursor past the box keeps the tank flat out while still steering. The boxes themselves are now upstream's size, and the heading bar, altimeter, and voice readout follow them. ### Fixed - The ground texture no longer swims against the obstacles standing on it. The ground was one 8000 unit quad, so everything near the camera fell on a single enormous triangle whose texture coordinates drift as the view moves. It now follows BZFlag's own ground: a 128 unit patch that tracks the eye, skirted out to the edge of the world, with the texture pinned to world coordinates. - Ground jitter while driving. Every frame spent the time between animation callbacks as movement, which includes however long the main thread took to reach the callback, rather than the frame's own timestamp; each displayed frame therefore advanced slightly too far or not far enough. The step now comes from the frame timestamp, and is capped so returning to a hidden tab no longer spends the whole gap at once. - The mouse mapping ignored the targeting box it was drawn against: full deflection sat at 35% of the window's width and 33% of its height instead of the box edge, and there was no dead zone, so the tank crept whenever the cursor was a pixel off centre. - Opening the debug HUD moved the chat panel's left edge, and moved its right edge one toggle late -- chat cleared the panel's corner only after the panel had closed again. Chat now keeps its left edge where it is and gives up its right edge exactly while the panel is open. ## [1.0.44] - 2026-09-01 ### Added - Team scores in team mode, kept the way bzfs keeps them: a kill across teams wins one for the killer's team and loses one for the victim's, killing a team mate costs two and killing yourself costs one, and rogues and observers score for nobody. A team's tally resets when its first player joins an empty team, and survives the team emptying out. - A team scoreboard above the player list, in the HUD and in XR, showing `score (wins-losses) size` per team sorted by score, the way BZFlag's does. - Team standings reach a joining player in the `init` payload, and every change broadcasts a `teamUpdate` carrying the same size, wins, and losses per team that BZFlag's `MsgTeamUpdate` does. ### Changed - Radar zoom out no longer needs the shift key. It answered to `+` alone, which is Shift and `=`, while zoom in answered to a bare `-`; both are now one unshifted key. - Firing moved from the space bar to Enter, as BZFlag does it, leaving the space bar free for dropping a flag. The left mouse button still fires, and so do a trigger in XR, a gamepad, and the on-screen button. ### Fixed - The browser no longer acts on the keys the game uses. Tab moved focus onto the HUD buttons, where the next Space press pushed a button instead of firing, and Firefox opened quick find on `/` and kept the keyboard. Modified keys are untouched, so browser and OS shortcuts still work. ## [1.0.43] - 2026-08-31 ### Added - Render capability detection, read from the renderer's own WebGL context. A feature the context cannot support is now switched off along with the UI that offers it: no stencil bits means no projected shadows, and a context near the WebGL 1 floor for fragment uniforms means Dynamic Lighting reads Unavailable rather than silently drawing nothing. - Every launch logs its `renderer.capabilities` line, so the machines this runs on can be compared before any quality policy is built on them. ## [1.0.42] - 2026-08-31 ### Removed - Clicking the page no longer enters VR when a headset launch could not open the session on its own. The canvas covers most of the window, so a click meant for the game put the player in VR uninvited, and the VR button was already there for anyone who wanted it. - Leaving VR no longer closes the window on a headset launch. It could not tell a player asking to leave VR from the headset ending the session, so Exit VR quit the app. ## [1.0.41] - 2026-08-31 ### Added - A headset browser launching the installed app asks for an immersive session ahead of the rest of the client, so an app launched from its own icon can open in VR with no 2D landing page. - The XR menu asks for a name, team, and tank before an unjoined player joins, standing in for the entry dialog, which an immersive session cannot show. - Name and MOTD can be typed in XR through the headset's system keyboard. Headsets without one mark those rows Desktop only. - `[INSTALL]` log lines record the manifest and icon fetches that make up an install, with the browser that asked, since which icon a launcher takes is documented nowhere. ### Fixed - Icons revalidate instead of being cached for a week, and the manifest points at a URL carrying each file's timestamp. A launcher keeps whichever icon it was shown at install time, so a stale one outlived every cache it came from. - The Settings Install row reads Installed inside the installed app. It asked whether the display mode was `standalone`, which an app launched from a `display: fullscreen` manifest is not, so the app reported itself uninstallable. ### Changed - Every icon is green. BZFlag's marks are red and its forums are blue, so the colour is what tells a bzo icon from a BZFlag one; the manifest and page theme colour follow it. - Icon files are named for the manifest role they fill -- `any-`, `maskable-`, `tile-` -- with no product prefix, since nothing but icons lives in that directory. - The Settings Install row reads Browser menu where the browser never offers the install event, rather than Unavailable. Safari, Firefox and the headset browsers all install from their own menus. - Headset browsers are served their own app icons. A phone launcher crops a maskable icon and needs the art padded inside it; the Meta Quest app library letterboxes the same file and needs it padded not at all, which left the mark at half the tile inside a ring. The manifest, already generated per request, now branches on the user agent. - Leaving VR closes the window when the app was launched from a headset icon, instead of dropping the player onto a flat window they have no use for. - A headset launch keeps asking for its immersive session on each signal that could carry the activation it needs -- window load, focus, page show, visibility, and the Launch Handler -- rather than only once at load. If none of them lands, the first click in the window enters VR. - The radar range starts at Medium every session and is no longer remembered. It matches BZFlag's `displayRadarRange` default, and a headset has no way to zoom the radar, so a level saved on a desktop no longer follows the player into VR. - The one-tap VR button beside the settings gear is shown only on a device with a headset. Chrome on Android reports VR support on any phone through Cardboard, which put the button under the player's thumb during play; VR Mode is still in the Settings menu there. - Controller input is dropped whenever the headset reports the session unfocused, rather than only when it is hidden, so a stick held while the system keyboard or the headset's own menu is up no longer drives the tank. ## [1.0.40] - 2026-08-31 ### Added - Installable as an app on mobile, desktop, and Meta Quest. The web app manifest is generated per request, so a server installs under the host it was reached at and two servers appear as separate apps. - Added the BZFlag application icon at up to 1024x1024 for launchers and home screens, including maskable variants that survive Android's circular crop and an Apple touch icon. The simple tank mark stays the browser tab icon. - Added a service worker. Images, audio, models, and Three.js load from disk; HTML and JavaScript revalidate on every start, so a cached client can never outlive the server it talks to. - Added an Install App row to Settings, offered only when the browser reports the game is installable and not already installed. - Added `maps/collision-test.bzw` and an optional `testSpawn` block in `server.json`, which place a named player at a fixed position for automated collision testing. - Added `npm run check:shared-pairs`, which fails when a `public/*.mjs` and `server/*.cjs` pair drift apart. A drifted pair does not throw; the client and server just disagree about geometry. ### Changed - Tanks collide as BZFlag's oriented 2.8 x 6.0 box rather than a 4-unit circle, so a tank is narrower across and longer front to back, and turning near a wall behaves as it does upstream. Every tank uses this box whatever model is selected, so the model stays cosmetic. - Movement resolves the way BZFlag does: on contact the timestep is searched for the last moment the tank was clear, then the velocity component along the surface normal is cancelled and the rest of the step is spent sliding. This replaces expanding obstacles by the tank radius, which cannot work for a rotated box. - Serve Three.js from the installed dependency instead of a CDN, so the game has no third-party origins and loads on a headset or a LAN with no route to the internet. - Fire rate is limited by shot slots alone, matching bzfs, which has no elapsed-time check. The previous reload timer compared consecutive shots one reload apart, the interval network jitter lands in, and rejected honest shots. - Rejected shots are logged as anti-cheat events and counted in the periodic summary, and shot logs use the `shotBegin` and `shotEnd` names the protocol already uses. - `npm run dev` restarts the server if it exits, so a crash no longer leaves it stopped. - Settings toggles show On in green and Off in red. Off was previously green, which read as backwards. ### Fixed - A malformed WebSocket frame from any client crashed the whole server. Sockets had no error listener, so a protocol violation became an uncaught exception. - Clicking the settings, VR, or player-name controls no longer fires the tank. - Clicking outside a dialog closes it without firing, and the next click fires normally. - Driving off the edge of an obstacle no longer strands the tank. Support and standing-on-top now use the same test, rather than a margin tuned for the old circular footprint. - `public/favicon.ico` was a text file containing a data URI, so any client requesting the default icon path received garbage. It is now a real multi-size icon. - Styles are revalidated rather than cached for a week, so a CSS change reaches players without a forced reload. - The page title and iOS home-screen name identify the server rather than reading "Battlezone Online" on every host. ## [1.0.39] - 2026-08-30 ### Added - Added the BZFlag tank-appeared sound when a tank spawns, which had no sound before. - Added BZFlag's spawn animation, growing a tank in from 1% to full size over 0.64 seconds. - Added a muzzle flash when a shot is fired. - Added BZFlag's jump jets: four flames under the tank that fire on a jump and fade as it rises, with a warm light while they burn. - Added a spinning collar that rides along with a shot after it passes through a teleporter. ### Changed - Matched BZFlag's reload timing: a shot slot now returns after the shot lifetime divided by the number of slots, 700ms with the shipped settings rather than a flat 1000ms. Set `shotReloadTime` in `server.json` to override. - Replaced the procedurally generated shot, explosion, jump, and landing sounds with the BZFlag samples they were imitating. - Moved gameplay audio into `public/audio/` and preloaded every sample on map entry. - Matched BZFlag's sound attenuation, using inverse rolloff from a reference distance of 20 tank radii. - Removed per-sound volume levels, so samples keep the relative balance they were mixed with, as in BZFlag. Landing volume no longer varies with impact speed. - Logged every rejected shot as `[SHOT_REJECT]` with its reason. Observer, dead-player, and malformed-direction rejections previously failed silently, hiding exactly the cases where the client and server disagree. ## [1.0.38] - 2026-08-30 ### Fixed - Fixed players rubber-banding down pyramid slopes. The server rejected moves with no tolerance, but positions are sent rounded to 0.01, which is coarser than the margin the client keeps while sliding, so most frames of a slide were rejected and rolled back. - Fixed tanks being held in mid-air by a distant inverted pyramid, and being unable to fall anywhere on the map afterwards. - Fixed tanks freezing against steep pyramid faces, including in mid-air while falling, when no slide surface was reported. - Fixed inverted pyramid collision on the server, which treated every inverted pyramid as upright and disagreed with the client about roughly a fifth of the space around it. - Fixed tanks clipping into the edges of pyramids, where a tank whose centre sat outside the base footprint was not tested at all. ### Changed - Added `antiCheat.collisionSlack` so the server validates movement slightly more permissively than the client, as the protocol's rounded positions require. - Replaced both ad-hoc pyramid collision routines with shared geometry that mirrors BZFlag, so the client and server evaluate the same solid volume. ## [1.0.37] - 2026-08-30 ### Added - Added immersive XR Join, Help, Voice, and Operator screens linked from XR Settings. - Added XR team/tank selection with authoritative rejoin, voice controls, and controller-accessible operator map and shot-limit actions. - Added client/server parity checks for player team normalization so the two copies of the rules cannot drift apart. ### Changed - Increased dialog and control text sizes across desktop, mobile, and XR, with scrolling XR menu rows to preserve readability. - Disabled Anaglyph 3D while XR is active because the rendering modes are incompatible. - Added Player Options as the first Settings destination and changed the player-name shortcut to open the main Settings menu. - Moved agent instructions and project memory into a single `AGENTS.md` shared by all coding agents. ### Fixed - Fixed the reported client version, which was pinned to an old release and is now derived from the release tooling. - Fixed team names with surrounding whitespace being rejected by the client while the server accepted them. ## [1.0.36] - 2026-08-30 ### Added - Added shared dialog navigation and input ownership for keyboard, mouse, touch, gamepad, and XR controllers. - Added a BZFlag-style Team selector with Automatic, Rogue, Observer, and enabled color teams. - Added authoritative team-mode configuration through server JSON and BZW `-c`, `-offa`, `-autoTeam`, and six-team `-mp` options. - Added an immersive XR Settings panel opened by either controller stick, with `Exit VR` as the first choice. - Added focused input-context, player-team, team-mode, and shot-limit checks to the release validation suite. ### Changed - Updated HiX to enable team play with ten slots each for Rogue, Red, Green, Blue, Purple, and Observer. - Updated XR controls so either controller can navigate menus, activate actions, fire, and jump. - Updated Settings, Help, Voice, Operator, and Entry dialogs to use a consistent responsive presentation and shared navigation behavior. - Updated player and voice state terminology from role/spectator to team/observer. ### Fixed - Fixed dialog input leaking into gameplay and stale controls continuing after input-context changes. - Fixed the Entry Team selector being skipped by Tab after reopening the join dialog. - Fixed automatic team assignment, per-team capacity enforcement, and BZFlag-compatible balancing behavior. - Fixed team colors not propagating to reused tank and ghost meshes after joining or changing teams. - Fixed team mode temporarily assigning random tank colors before join confirmation; random pastel colors are now limited to non-team-mode Rogues. ## [1.0.35] - 2026-08-29 ### Added - Added camera-attached WebXR HUD overlays for the radar, chat tabs, scoreboard, and shot cooldown indicators. - Added an empty test world for focused rendering and WebXR diagnostics. ### Changed - Updated WebXR session state handling to publish consistent lifecycle snapshots to subscribers. - Aligned WebXR HUD overlays on a shared camera plane with headset-oriented sizing and placement. ### Fixed - Fixed projectile heads, trails, and impact effects rendering behind the ground debug grid. - Fixed WebXR HUD visibility by rendering overlays through the camera-attached XR path. ## [1.0.34] - 2026-08-28 ### Changed - Updated projected ground shadows to use a BZFlag-style stencil path so overlapping casters do not over-darken by object count. - Updated ground debug grid visibility to follow the existing debug-geometry toggle. ### Fixed - Fixed horizon and movement-related shadow flashing by preserving last valid projected shadow meshes and hardening stencil/decal layering. - Fixed shadow occlusion so obstacle geometry correctly blocks ground shadow darkening. - Fixed ground/grid/shadow draw ordering so debug grid rendering can be layered beneath shadow darkening when enabled. - Follow-up on closed issue #22 for ground/shadow visual stability. ## [1.0.33] - 2026-08-28 ### Added - Added explicit BZFlag-style player teleport packets (`tp`/`pt`) so teleports are replicated as authoritative events instead of being inferred from movement. - Added the shipped BZFlag teleport sound asset at [public/teleport.wav](public/teleport.wav) and preload/caching for required gameplay audio during map initialization. ### Changed - Updated player teleports to validate from the client's predicted source-side state and preserve turn, jump direction, vertical velocity, and airborne horizontal velocity through teleport exits. - Updated server debug packet logging to use a consistent `[DEBUG] Player "name": ...` format. ### Fixed - Fixed teleporter frame/interior collision handling so tanks pass through active portals instead of sliding on them. - Fixed blocked or out-of-bounds teleporter exits by rejecting invalid destinations instead of placing players outside the map. - Fixed jump-through-teleporter kinematics so falling/rising state, turning, and post-teleport extrapolation stay aligned without false anticheat spikes. - Fixed projectile teleport ping-pong on stacked upper/lower teleporters by extending shot re-entry blocking to cover the portal breadth after exit. ## [1.0.32] - 2026-08-28 ### Added - Added BZFlag-style tabbed chat panel with `All`, `Chat`, `Server`, `Misc`, and `Debug` tabs plus per-tab scrollback and unread indicators. - Added chat navigation and compose shortcuts: direct tab select (`1`-`5`), tab cycling (`[`/`]`), reply to last direct sender (`.`), and message-nemesis targeting (`,`). ### Changed - Aligned chat packet naming/content toward BZFlag semantics by replacing `chat` packets with `message` packets carrying `src`, `dst`, `msgType`, and `text`. - Updated chat rendering to distinguish message categories and direct-message direction in the panel (`[->name]` outbound and `[name->]` inbound) similar to BZFlag formatting. - Updated radar zoom hotkeys to use `+`/`-` (and numpad equivalents), freeing `[`/`]` for chat tab cycling. ### Fixed - Fixed private-message delivery and sender-name display by handling player IDs as string identifiers in both client and server message routing. - Fixed message misclassification where normal chat could appear as `[SERVER]` when legacy/alternate message fields were received. - Fixed chat panel click propagation so selecting chat tabs no longer triggers firing. - Fixed cross-monitor chat text clipping and improved desktop layout so chat shrinks away from the debug HUD when there is sufficient horizontal space. ## [1.0.31] - 2026-08-27 ### Added - Added a unified radar world-to-panel transform path so obstacle, projectile, and tank rendering now share one conversion pipeline. ### Changed - Simplified radar math by centralizing world-relative rotation and panel scaling into shared helpers used by all radar entities. ### Fixed - Fixed radar obstacle orientation regression for large rotated walls (for example `ne_xwall`) introduced by the polygon clipping rewrite. ## [1.0.30] - 2026-08-27 ### Added - Added authoritative shot lifecycle diagnostics in `server.log` with `[SHOT_START]`, `[SHOT_TP]`, and `[SHOT_END]` entries to trace teleporter traversal and termination causes. ### Changed - Updated shot lifecycle packet names to BZFlag-style semantics: `shotBegin` and `shotEnd` replace `projectileCreated` and `projectileRemoved`. - Updated shot expiration to use lifetime-budget semantics (`shotRange / shotSpeed`) so teleport traversal does not incorrectly shorten lifetime based on straight-line displacement from spawn. - Updated teleporter frame/crossing classification to use BZFlag-matching tolerance (`1e-6`) and harmonized server/client teleporter aperture math with rendered teleporter geometry. ### Fixed - Fixed false shot-end explosions on valid teleporter traversals by separating teleporter frame-hit detection from generic obstacle collision checks and excluding teleporter solids from post-teleport projectile collision tests. - Fixed immediate teleporter ping-pong loops by adding short re-entry blocking for the destination teleporter after teleport exit. - Fixed radar obstacle clipping for large rotated structures (for example corner x-walls) by clipping obstacle polygons to the radar square instead of culling by coarse bounds. ## [1.0.29] - 2026-08-26 ### Fixed - Fixed Docker image missing the `server/` directory, causing a crash on startup with `Cannot find module './server/shot-limits.cjs'`. ## [1.0.28] - 2026-08-25 ### Added - Added radar zoom controls with keyboard bindings and a settings-panel preset toggle. ### Changed - Updated the radar to a square-style panel layout with square-consistent world scaling. - Updated radar culling for tanks, shots, and obstacles to use rectangular visibility rules that match the square display. - Updated off-range tank indicators to sit closer to the panel edge while preserving stable edge projection. ### Fixed - Fixed near-edge radar transitions so tanks remain rendered as arrows (including partial clipping) before switching to edge dots. ## [1.0.27] - 2026-08-25 ### Added - Added a Quest-friendly XR shortcut: pressing either controller thumbstick button exits XR and opens the browser settings HUD. ### Changed - Updated XR locomotion to be right-stick-primary for forward/back movement, with left-stick fallback, so turning and movement can be handled with one thumb. - Updated XR session toggle UX messaging so intentional XR exits do not display a false "request failed" error. ### Fixed - Fixed XR controller axis handling robustness across WebXR gamepad layouts by adding stick-axis fallback support for both `[0,1]` and `[2,3]` axis pairs. ## [1.0.26] - 2026-08-25 ### Added - Added subtle team-color shading for base tiles on the radar while preserving vertical opacity tracking. ### Changed - Updated cardinal indicator colors to fixed team-color mapping independent of map layout: north=red, east=green, south=blue, west=purple in both 3D markers and radar. - Increased radar base tint strength so team ownership is easier to read at a glance. - Updated `hix.bzw` object naming to use directional prefixes consistently and keep team names on bases only. - Renamed base source texture assets to shorter filenames: `base_top_source.png` -> `base_top.png`, `base_wall_source.png` -> `base_wall.png`. ### Fixed - Fixed inconsistent directional naming in `hix.bzw`, including mixed prefix/suffix patterns and duplicate platform direction labels. ## [1.0.25] - 2026-08-25 ### Added - Added BZFlag teleporter textures (`caution.png`, `telelink.png`) and BZ-inspired teleporter frame/portal rendering with animated portal visuals. - Added base source textures (`base_top.png`, `base_wall.png`) and team-aware base rendering assets for map-defined bases. ### Changed - Updated teleporter geometry and face rendering toward BZFlag parity, including border framing, portal face behavior, and map-driven placement/orientation. - Updated mountain rendering visibility on large maps by expanding view-distance handling so mountains remain visible at BZFlag-like perimeter placement. - Updated base texture mapping to match BZFlag behavior: top/bottom fixed UV mapping (single texture repeat) and side faces using size-based repeats. ### Fixed - Fixed a client texture colorization TypeError in base tint generation (`drawImage` source type mismatch). - Fixed base team colorization so map teams render distinctly as red/green/blue/purple instead of all appearing red/gray. ## [1.0.24] - 2026-08-25 ### Added - Added deterministic projectile impact-point refinement on the server for obstacle and map-edge hits, so impact billboards render at stable contact points. ### Changed - Updated remote projectile spawn handling to use authoritative server coordinates instead of wall-clock lead compensation. - Updated remote extrapolation stop handling to clamp only when the replicated remote state is an explicit full stop. ### Fixed - Fixed missing shot-end billboard effects for other players by always honoring authoritative projectile removal coordinates. - Fixed impact visuals landing inside geometry by backtracking projectile/map-edge collision points before broadcast. - Fixed delayed remote stop visibility by forcing a dead-stick move update at local inertial stop (`fs=0`, `rs=0`) and replicating that stop state immediately. ## [1.0.23] - 2026-08-25 ### Added - Added BZFlag explosion atlas assets (`explode1.png`, `explode2.png`) and a billboarded shot-impact animation path for projectile termination effects. ### Changed - Updated local firing visuals so your own shots render immediately on send, then reconcile to the server projectile ID when the authoritative echo arrives. - Updated shot-impact protocol handling to carry an explicit projectile end reason code and impact position data. ### Fixed - Fixed delayed local shot visibility that previously made shots appear far from the barrel under network latency. - Fixed shot-end visuals to only trigger explosion billboards for BZFlag-style explode reason (`reason === 0`). ## [1.0.22] - 2026-08-24 ### Added - Added an explicit naming migration note in the README for `compose.yml`, `server.json`, and `example-server.json`. ### Changed - Renamed `docker-compose.yml` to `compose.yml`. - Renamed `example-server-config.json` to `example-server.json`. - Standardized default runtime config paths and examples to use `server.json` naming. ### Fixed - Fixed Docker bind-mount ownership friction by pinning the container runtime user to UID/GID `1000:1000`. - Fixed dev watcher config to watch `server.json` after the naming migration. ## [1.0.21] - 2026-08-24 ### Added - Added persistent runtime map storage for uploads and operator-managed maps via a runtime maps directory (defaulting to `/data/maps` in Docker). ### Changed - Updated map discovery and selection to merge runtime-uploaded maps with bundled image maps. - Updated Docker documentation to explain where server config and uploaded maps persist by default. ### Fixed - Fixed Docker map switching reload behavior by restarting the process after operator map changes when not running under nodemon. - Fixed silent operator failures by surfacing generic server success/error responses in the client and logging config write errors with paths server-side. ## [1.0.20] - 2026-08-24 ### Added - Added an explicit project policy that distribution artifacts remain public by default, including container images and release downloads. ### Changed - Migrated repository and container registry references from `BZFlag-Dev` to `timriker` across docs, scripts, runtime links, and Docker defaults. ### Fixed - Fixed post-transfer pull failures caused by stale owner paths by updating published image references to `ghcr.io/timriker/bzo`. ## [1.0.19] - 2026-08-24 ### Added - Added a repository security policy in `SECURITY.md` covering reporting flow, support scope, and update guidance. ### Changed - Added a controls documentation consistency check (`npm run check:controls-docs`) and integrated it into `npm run check`. ### Fixed - Applied safe dependency security updates in the lockfile to address known advisories in `express`, `body-parser`, `qs`, `js-yaml`, and `brace-expansion`. - Aligned README and in-game Help controls so documented keybindings match the current implementation. ## [1.0.18] - 2026-08-24 ### Added - Added release-time Node.js compatibility validation on both Node.js `18.19.1` and `24.19.0`. ### Changed - Switched container publishing to a single Ubuntu `26.04` image lane while keeping multi-arch (`linux/amd64`, `linux/arm64`) manifests. - Updated the Docker runtime install path to pinned Node.js `24.19.0` binaries on Ubuntu `26.04`. ### Fixed - Fixed release process drift by aligning CI, release workflow, Dockerfile, and README with the current Node compatibility and Docker publishing policy. ## [1.0.17] - 2026-08-24 ### Added - Added clone-safe ghost label binding so ghost name labels stay connected after tank cloning. ### Changed - Updated jump-path debug rendering to use a momentum-only airborne projection and always terminate on ground impact. ### Fixed - Fixed stale ghost player names (for example showing "Player 1") by ensuring ghost labels receive the same name updates as tank labels. - Removed unnecessary per-update name prefix churn from packet-motion debug labels. ## [1.0.16] - 2026-08-24 ### Added - Added Nearby WebRTC voice chat with in-game microphone controls and per-player nearby peer routing. - Added a dedicated Voice settings dialog opened from the main Settings HUD. ### Changed - Moved the main browser entrypoint from `game.js` to `client.js`. - Updated HUD panel behavior so opening Help, Voice, or Operator hides the Settings HUD. ### Fixed - Fixed mobile/desktop firing cadence so holding fire respects `shotReloadTime` on the client and server. - Fixed Help panel usability by adding proper scrolling and a close button. - Fixed movement collision precedence so driving on top of one obstacle no longer allows moving through overlapping obstacles or world boundaries. ## [1.0.15] - 2026-08-23 ### Added - Added a more stable WebXR foundation with cleaner session lifecycle handling and controller/input state isolation. ### Changed - Updated the client to Three.js 0.185.1 and synced the browser import map and dependency versions to match the updated renderer. - Improved renderer scheduling so the app handles normal playback and XR sessions more predictably. ### Fixed - Fixed wall impacts while jumping so tanks keep the vertical component of their jump and resume a valid horizontal trajectory after contact instead of sticking to the obstacle. - Stabilized WebXR startup, visibility changes, controller changes, and session teardown paths to reduce stale input and renderer issues. ## [1.0.14] - 2026-04-08 ### Fixed - Synchronized release metadata so `package-lock.json` now matches `package.json` versioning for the published package (`1.0.14`). - Cut a follow-up patch release to carry the metadata correction without rewriting the previously published `v1.0.13` release. ## [1.0.13] - 2026-04-07 ### Added - Added projected planar (stencil-style) shadows for tanks: each visible tank casts a soft ground shadow computed by projecting its geometry onto the ground plane along the sun direction. ### Changed - Switched from real-time Three.js shadow mapping (PCFSoftShadowMap) to projected planar shadows for better performance; shadow map generation on lights is now disabled. - Updated npm dependencies to address audit findings. ## [1.0.12] - 2026-04-01 ### Changed - Updated projectile/shot behavior to better match classic BZFlag feel and timing. - Refined shot handling so client and server behavior stays aligned with the BZFlag-style firing model. ## [1.0.11] - 2026-04-01 ### Added - Added BZFlag mountain, ground, bolt, and shot-tail textures to the client asset set. - Added BZFlag-style onscreen shot-slot indicators beside the target HUD. ### Changed - Updated mountains and ground to use BZFlag-style placement, scale, and texture repetition for a closer classic battlefield look. - Retuned world lighting toward BZFlag day/night colors while keeping `bzo`'s world-time cycle. - Switched projectile rendering from simple glowing spheres to BZFlag-inspired tinted bolt sprites with matching colored tails. - Updated shot speed/range handling so server simulation, client rendering, radar visibility, and config defaults all use the same BZFlag-style values. - Switched firing behavior from cooldown-only shooting to BZFlag-style shot slots, with the example config defaulting to one slot and the runtime server config set to three. ### Fixed - Fixed projectile desync that made local shots appear much slower than the server-tracked projectile speed. - Fixed shot-slot HUD behavior so each slot tracks its actual active projectile instead of multiple slots animating together. ## [1.0.10] - 2026-03-31 ### Added - Added BZFlag obstacle textures for boundaries, boxes, pyramids, tank treads, and tank body detailing. - Added a BZFlag-style loading overlay that keeps chat available while delaying active join until render-critical world and tank assets are ready. - Added a new `wheeled6` tank model option with a six-wheel armored-car silhouette. - Added documentation for the supported tank OBJ naming contract in `docs/tank-model-format.md`. ### Changed - Switched the default player tank model to `bzflag`, renamed the old default model to `modern`, and renamed the split BZFlag model asset to `bzflag`. - Updated tank model selection and server-side model discovery to prefer the supported selectable models and hide source-only OBJ assets from the menu. - Updated cloud height placement to float above the tallest obstacle by roughly one jump height. - Added configurable BZFlag-style fog mode, density, start, and end settings while keeping fog color driven by time-of-day. ### Fixed - Fixed startup races that could leave the local tank partially initialized until switching models by gating gameplay join on render readiness. - Fixed blank tank selection states caused by exposing `tank.obj` as a selectable model. - Fixed wheel-face clipping on treaded models by nudging wheel meshes slightly outward from the tread surfaces. - Fixed the `wheeled6` model so it renders as a true wheel-only vehicle without fake tread geometry. ## [1.0.9] - 2026-03-27 ### Changed - Increased scene fog start/end distances (120–500) to better match the 1:1 BZFlag world scale. ### Fixed - Fixed obstacle rotation for all rotated boxes, walls, and teleporters parsed from `.bzw` maps by correcting the BZFlag +Y→Three.js -Z axis-flip compensation in the rotation formula. ## [1.0.8] - 2026-03-27 ### Added - Added BZFlag-style spawn visuals with a short ground flash ring and vertical spawn burst on join and respawn. ### Changed - Updated first-person camera and shot origin alignment to use model-derived muzzle offsets for closer BZFlag parity. - Updated first-person FOV behavior to use BZFlag-style horizontal FOV conversion by display aspect. - Updated jump defaults to BZFlag-like values (`jumpVelocity: 19`, `gravity: 9.8`) and aligned landing flash/squish timing to BZFlag feel. ### Fixed - Fixed server/client gravity configuration flow so gravity is configurable server-side and propagated through game config. - Fixed landing feedback consistency by triggering effects on landing transitions without local threshold suppression. ## [1.0.7] - 2026-03-26 ### Fixed - Make the `prepare` script skip Husky installation when dev dependencies are omitted so container builds using `npm ci --omit=dev` no longer fail with `sh: 1: husky: not found`. ## [1.0.6] - 2026-03-26 ### Fixed - Replace `docker/build-push-action` (Buildx) with plain `docker build` + `docker push` commands to eliminate unexplained BuildKit exit code 127 failures and get clear build output in CI logs. ## [1.0.5] - 2026-03-26 ### Fixed - Add `no-cache: true` to Docker build step to prevent stale BuildKit layer cache from masking base image changes. - Remove unused QEMU setup step; only `linux/amd64` is targeted so QEMU is not needed. ## [1.0.4] - 2026-03-26 ### Changed - Switch Docker base image from `node:20-slim` to `ubuntu:24.04` with OS-provided Node.js 18 and npm, matching the Ubuntu 24.04 development environment and avoiding mysterious `npm ci` exit code 127 failures in GitHub Actions Buildx. ## [1.0.3] - 2026-03-26 ### Fixed - Restrict Docker image build to `linux/amd64` to avoid QEMU emulation failures that caused `npm ci` to exit with code 127 when building `linux/arm64` on GitHub Actions runners. ## [1.0.2] - 2026-03-26 ### Fixed - Switch Docker base image from `node:20-alpine` to `node:20-slim` so that `npm` is available during the container build step and the release workflow succeeds. ## [1.0.1] - 2026-03-26 ### Added - Local Git hooks now lint staged JavaScript before commit and run full checks before push. ### Changed - Release workflow now initializes QEMU before Buildx to support multi-architecture Docker image publishing. ### Fixed - Fixed release automation gap that could fail container publishing during tagged releases. ## [1.0.0] - 2026-03-26 ### Added - Tag-gated release automation that validates `package.json` and `CHANGELOG.md` before publishing. - GitHub Container Registry publishing for versioned Docker images. - Docker packaging with a persistent `/data` volume for runtime config. - `/source` route and in-app source-code link to satisfy AGPL network source availability. - Release helper scripts for preparing, validating, and extracting changelog entries. ### Changed - Updated licensing headers across source files with copyright and source references. - Expanded documentation for releases, installation, configuration, and update strategy. - Server startup now bootstraps a runtime config from `example-server.json` when no config exists. ### Fixed - Cleaned up release metadata and project packaging details so shipped artifacts are consistent.