{"id":11,"date":"2026-10-27T15:33:17","date_gmt":"2026-10-27T15:33:17","guid":{"rendered":"https:\/\/speedsim.nl\/?page_id=11"},"modified":"2026-10-27T16:50:21","modified_gmt":"2026-10-27T16:50:21","slug":"speedsim","status":"publish","type":"page","link":"https:\/\/speedsim.nl\/","title":{"rendered":"SpeedSim"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"11\" class=\"elementor elementor-11\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-29ee55c e-flex e-con-boxed e-con e-parent\" data-id=\"29ee55c\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-19f7a8e elementor-widget__width-inherit elementor-widget elementor-widget-html\" data-id=\"19f7a8e\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<!-- Paintball Callout Trainer \u2014 paste this into an Elementor \"HTML\" widget -->\n<div id=\"pbf-app\">\n  <div class=\"pbf-splash\" aria-hidden=\"true\"><img decoding=\"async\" src=\"https:\/\/speedsim.nl\/wp-content\/uploads\/2026\/10\/SpeedSim-by-FROBO.png\" alt=\"\"><\/div>\n  <div class=\"pbf-head\">\n    <div class=\"pbf-modes\" role=\"group\" aria-label=\"Mode\">\n      <button data-mode=\"play\" class=\"on\" aria-pressed=\"true\"><i>\u25b6<\/i><span><b>Play<\/b><small>Walk &amp; shoot<\/small><\/span><\/button>\n      <button data-mode=\"build\" aria-pressed=\"false\"><i>\ud83d\udee0<\/i><span><b>Build<\/b><small>Design fields<\/small><\/span><\/button>\n      <button data-mode=\"quiz\" aria-pressed=\"false\"><i>\ud83c\udfaf<\/i><span><b>Practice<\/b><small>Learn callouts<\/small><\/span><\/button>\n      <span class=\"pbf-sep\"><\/span>\n      <button data-mode=\"strat\" aria-pressed=\"false\" title=\"Plan strategies: players, paths, shot lines and notes\"><i>\ud83d\udccb<\/i><span><b>Strategy<\/b><small>Plan tactics<\/small><\/span><\/button>\n    <\/div>\n    <div class=\"pbf-tools\">\n      <span class=\"pbf-vis\" title=\"People who have this page open right now\">\ud83d\udc65 <b class=\"v-n\">\u2013<\/b> online<\/span>\n      <button class=\"pbf-ico h-help\" title=\"Help &amp; tutorial\" aria-label=\"Help and tutorial\">?<\/button>\n      <button class=\"pbf-ico h-set\" title=\"Settings (mouse, view, weapon colours, keys)\" aria-label=\"Settings\">\u2699<\/button>\n      <div class=\"pbf-help\" hidden>\n        <button data-tour=\"full\">\u25b6 <b>Quick tour<\/b><small>All four modes in one minute<\/small><\/button>\n        <button data-tour=\"play\">Play tour<\/button>\n        <button data-tour=\"build\">Build tour<\/button>\n        <button data-tour=\"quiz\">Practice tour<\/button>\n        <button data-tour=\"strat\">Strategy tour<\/button>\n        <button data-tour=\"keys\">\u2328 Keys &amp; controller<\/button>\n      <\/div>\n    <\/div>\n  <\/div>\n  <div class=\"pbf-bar\">\n    <div class=\"bar-g bar-view\">\n      <span class=\"gl\">View<\/span>\n      <button data-view=\"iso\">3D<\/button>\n      <button data-view=\"top\">Top<\/button>\n      <button data-view=\"red\">Red view<\/button>\n      <button data-view=\"blue\">Blue view<\/button>\n      <label class=\"chk\"><input type=\"checkbox\" class=\"f-lbl\"> Show names<\/label>\n    <\/div>\n    <div class=\"bar-g bar-act\">\n      <button class=\"f-lock\" title=\"Lock the layout so bunkers can't be placed or moved by accident\" hidden>\ud83d\udd13 Lock layout<\/button>\n      <button class=\"f-walk accent\" title=\"Walk through the field (WASD + mouse, click to shoot)\">\ud83d\udeb6 Walk<\/button>\n      <button class=\"f-spec\" title=\"Spectator mode: you do not play, but you can draw marks for the teams\">\ud83d\udc41 Spectator<\/button>\n      <button class=\"f-fs\" title=\"Fullscreen (press F while walking)\">\u26f6 Fullscreen<\/button>\n    <\/div>\n  <\/div>\n\n  <div class=\"pbf-main\">\n    <div class=\"pbf-stage\">\n      <canvas><\/canvas>\n      <div class=\"pbf-hud\"><\/div>\n      <div class=\"pbf-opts\"><\/div>\n      <div class=\"pbf-xh\" hidden><\/div>\n      <div class=\"pbf-net\" hidden><\/div>\n      <div class=\"pbf-hit\"><\/div>\n      <div class=\"pbf-watch\" hidden><\/div>\n      <div class=\"pbf-stbar\" hidden><button class=\"sb-prev\" title=\"Previous step (left arrow)\">\u2039<\/button><b class=\"sb-t\"><\/b><button class=\"sb-next\" title=\"Next step (right arrow)\">\u203a<\/button><\/div>\n      <div class=\"pbf-count\" hidden><\/div>\n      <div class=\"pbf-ammo\" hidden><\/div>\n      <div class=\"pbf-sb\" hidden><\/div>\n      <div class=\"pbf-ws\" hidden><\/div>\n      <div class=\"pbf-coach\" hidden>\n        <button class=\"c-draw on\" title=\"Draw on the field \/ move the view\">\u270f Draw<\/button>\n        <select class=\"c-for\" title=\"Who sees your marks\"><option value=\"2\">Both teams<\/option><option value=\"0\">Red team<\/option><option value=\"1\">Blue team<\/option><\/select>\n        <span class=\"c-cols\"><\/span>\n        <label class=\"chk\"><input type=\"checkbox\" class=\"c-arrow\" checked> Arrow<\/label>\n        <button class=\"c-play accent\" title=\"Stop spectating and play (between rounds)\">\ud83c\udfae Play<\/button>\n        <button class=\"c-sb\" title=\"Hold to show the scoreboard\">\ud83d\udcca Scoreboard<\/button>\n        <button class=\"c-undo\">\u21b6 Undo<\/button>\n        <button class=\"c-clear danger\">Clear<\/button>\n      <\/div>\n      <div class=\"pbf-touch\" hidden>\n        <div class=\"tj tj-l\"><div class=\"tj-k\"><\/div><\/div>\n        <div class=\"tj tj-r\"><div class=\"tj-k\"><\/div><\/div>\n        <button class=\"tb tb-fire\">FIRE<\/button>\n        <button class=\"tb tb-j\">\u2912 Jump<\/button>\n        <div class=\"tb-row\">\n          <button class=\"tb tb-q\">\u25c0 Peek<\/button>\n          <button class=\"tb tb-c\">Crouch<\/button>\n          <button class=\"tb tb-p\">Prone<\/button>\n          <button class=\"tb tb-e\">Peek \u25b6<\/button>\n        <\/div>\n        <div class=\"tb-top\">\n          <button class=\"tb tb-h\">\u21c4 Hand<\/button>\n          <button class=\"tb tb-g\">\u2691 Flag<\/button>\n          <button class=\"tb tb-b\">\ud83d\udcca<\/button>\n          <button class=\"tb tb-w\">\ud83d\udd2b<\/button>\n          <button class=\"tb tb-fs\">\u26f6<\/button>\n          <button class=\"tb tb-x\">\u2715 Exit<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"pbf-side\">\n      <div class=\"sd-head\">\n        <span class=\"sd-ico\" aria-hidden=\"true\"><\/span>\n        <div class=\"sd-tx\"><b class=\"sd-t\"><\/b><small class=\"sd-s\"><\/small><\/div>\n        <button class=\"sd-how\" title=\"A short guided tour of this mode\">? Tour<\/button>\n      <\/div>\n      <div class=\"sd-tabs\" role=\"tablist\"><\/div>\n      <div class=\"sd-body\">\n      <!-- MAPS (Play: load \u00b7 Build \/ Strategy: also save & share) -->\n      <details class=\"p-sec p-maps\" data-sec=\"maps\" open>\n        <summary>\ud83d\uddfa Maps<\/summary>\n        <h4>Premade maps<\/h4>\n        <div class=\"m-pre\"><\/div>\n        <div class=\"m-edit\">\n          <h4>Save this field<\/h4>\n\n        <div class=\"row\" style=\"margin-top:0\">\n          <label>Name <input type=\"text\" class=\"m-name\" maxlength=\"40\" placeholder=\"e.g. My local field\"><\/label>\n        <\/div>\n        <div class=\"row\">\n          <button class=\"m-save accent\">\ud83d\udcbe Save current field<\/button>\n        <\/div>\n        \n        <\/div>\n        <h4>My saved fields<\/h4>\n        <div class=\"m-list\"><\/div>\n        <div class=\"m-edit\">\n        <h4>Share &amp; import<\/h4>\n        <div class=\"row\" style=\"margin-top:0\">\n          <button class=\"m-dl\">\u2b07 Download as file<\/button>\n          <button class=\"m-open\">\ud83d\udcc2 Open file<\/button>\n        <\/div>\n        <div class=\"row\">\n          <button class=\"f-exp\">Copy field (JSON)<\/button>\n          <button class=\"f-imp\">Paste field<\/button>\n        <\/div>\n        <input type=\"file\" class=\"m-file\" accept=\".json,application\/json,text\/plain\" hidden>\n        <p class=\"note\">Your current field is also saved automatically in this browser. Saved fields stay in this browser; download a file to keep a copy or to share a field and open it again on any device. To import a field someone sent you, use \"Paste field\" or \"Open file\". Loading a map or importing a field replaces the current field. In an online game only the host can load a map, and everybody gets it automatically.<\/p>\n        <\/div>\n      <\/details>\n      <details class=\"p-sec p-stl\" data-sec=\"stl\" open>\n        <summary>\ud83d\udda8 3D print (STL)<\/summary>\n        \n        <p class=\"note\" style=\"margin-top:0\">Export the placed bunkers as 3D-print files, scaled down to a miniature field.<\/p>\n        <div class=\"row\" style=\"margin-top:0\">\n          <label>Miniature field length (cm) <input type=\"number\" class=\"st-len\" min=\"10\" max=\"2000\" step=\"1\" value=\"100\"><\/label>\n          <label class=\"st-chk\" style=\"display:flex;align-items:flex-end;gap:6px\"><input type=\"checkbox\" class=\"st-glass\" checked style=\"width:auto;margin:0 0 6px\"> Plexi: include the pane<\/label>\n        <\/div>\n        <p class=\"note st-info\"><\/p>\n        <div class=\"row\">\n          <button class=\"st-zip accent\">\u2b07 One STL per bunker (.zip)<\/button>\n          <button class=\"st-one\">\u2b07 Whole field as one STL<\/button>\n        <\/div>\n      <\/details>\n\n      <!-- WEAPON (Play + Practice) -->\n      <details class=\"p-sec p-wpn\" data-sec=\"wpn\">\n        <summary>\ud83d\udd2b Weapon<\/summary>\n        <div class=\"row\">\n          <button data-wpn=\"m4\">Rifle (full auto)<\/button>\n          <button data-wpn=\"pistol\">Pistol (semi-auto)<\/button>\n        <\/div>\n        <label>Bullet color<\/label>\n        <div class=\"row b-col\"><\/div>\n        <p class=\"note\">Switch with keys <b>1<\/b> \/ <b>2<\/b> while walking. Between rounds keys <b>1-5<\/b> pick your start spot instead (use <b>Shift+1-5<\/b> or click for the weapon).<\/p>\n      <\/details>\n\n      <details class=\"p-net\">\n        <summary>\ud83c\udf10 Play online (up to 5 per team)<\/summary>\n        <label class=\"chk\" hidden><input type=\"checkbox\" class=\"n-coach\"><\/label>\n        <div class=\"n-sec\">\n          <h5>You<\/h5>\n          <div class=\"row\">\n            <label>Your name <input type=\"text\" class=\"n-name\" maxlength=\"12\" placeholder=\"e.g. Dave\"><\/label>\n            <label>Team\n              <select class=\"n-team\">\n                <option value=\"0\">Red<\/option>\n                <option value=\"1\">Blue<\/option>\n              <\/select>\n            <\/label>\n          <\/div>\n        <\/div>\n        <div class=\"n-sec\">\n          <h5>Join a game<\/h5>\n          <div class=\"row\" style=\"align-items:flex-end\">\n            <label>Room code <input type=\"text\" class=\"n-code\" maxlength=\"5\" placeholder=\"e.g. K7M2Q\" autocapitalize=\"characters\"><\/label>\n            <button class=\"n-join\">Join<\/button>\n            <button class=\"n-list\">\ud83d\udd04 Server list<\/button>\n          <\/div>\n          <div class=\"n-servers\"><\/div>\n        <\/div>\n        <div class=\"n-sec\">\n          <h5>Host a game<\/h5>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"n-pub\"> \ud83c\udf0d Public server (show my game in the server list)<\/label>\n          <label class=\"n-snw\" hidden>Server name <input type=\"text\" class=\"n-sname\" maxlength=\"24\" placeholder=\"e.g. Dave's open game\"><\/label>\n          <div class=\"row\"><button class=\"n-host accent\">Host a game<\/button><\/div>\n        <\/div>\n        <div class=\"n-sec\">\n          <h5>Game<\/h5>\n          <div class=\"row\">\n            <label>Game mode\n              <select class=\"n-mode\">\n                <option value=\"free\">Free play<\/option>\n                <option value=\"match\">Match (team elimination)<\/option>\n                <option value=\"buzzer\">Buzzer (elimination + touch the enemy start zone)<\/option>\n                <option value=\"flag7\">Flag \u00b7 NSL rulebook v7.0 (May 2026)<\/option>\n              <\/select>\n            <\/label>\n          <\/div>\n          <div class=\"row\">\n            <button class=\"n-start accent\" hidden>\u25b6 Start match<\/button>\n            <button class=\"n-stop\" hidden>\u25a0 Stop match<\/button>\n          <\/div>\n          <div class=\"n-status note\"><\/div>\n          <div class=\"n-roster\"><\/div>\n          <div class=\"row\"><button class=\"n-leave danger\" hidden>Leave game<\/button><\/div>\n        <\/div>\n        <details class=\"n-help\">\n          <summary>\u2139 Game modes &amp; how it works<\/summary>\n          <details class=\"n-h\"><summary>Match (team elimination)<\/summary><p class=\"note\">the host picks the mode and presses Start (each team needs at least one player). Everyone is frozen on their team's start zone, you hear 3 low beeps and a higher one = go. A player who gets hit is out until the round ends; the round is won when the whole other team is out. The next round starts automatically. The host can press Stop (or exit Walk) to end the match. Between rounds (while the winner is shown and \"Get ready\" is displayed) everybody can still change weapon, team or spectator mode, until the countdown starts (the Flag mode does not allow team changes during a match). The teams are balanced automatically when a round starts: if one team has more than one player extra, the player who switched most recently is moved.<\/p><\/details>\n          <details class=\"n-h\"><summary>Buzzer<\/summary><p class=\"note\">the same rules as the team elimination match, with one extra rule: when a player touches the enemy start zone, that player's team wins the round immediately. Defend your own start zone!<\/p><\/details>\n          <details class=\"n-h\"><summary>Flag \u00b7 NSL rulebook v7.0<\/summary><p class=\"note\">one team attacks and holds the bandana-sized flag, the other defends. The attackers get 3 tries per round, then the roles swap (4 rounds, or 15 minutes of play); the most points wins. <b>Scoring:<\/b> carry the flag fully over the yellow lines into zone 1 \/ 2 \/ 3 = 1 \/ 2 \/ 3 points. <b>Flag hang<\/b> (touch the enemy start zone while carrying the flag) = 12 \/ 11 \/ 10 points on try 1 \/ 2 \/ 3, replacing the zone points of earlier tries in that round. It is followed by the <b>Bonus try<\/b>: the player who hung the flag plays 1v1 against one defender (the other players sit out) and earns +2 points by hanging the flag within 30 s. <b>Snatch:<\/b> a defender who touches the flag (carried or on the floor) ends the round and the zone points where the flag lies still count; touching the attackers' start zone also snatches, but only before a buzzer has sounded. A hit flag carrier drops the flag on the spot. <b>G<\/b> hands the flag to a teammate within 2 m, or drops it; a player who dropped the flag may not pick it up again in that try. <b>Buzzer:<\/b> from 30 s into a try, when a team is fully eliminated its buzzer sounds: the attackers get 3 s to hang or advance, or the defenders get 3 s to snatch. Hit players are out until the next try. Defenders cannot score. <b>Mercy rule:<\/b> the match ends as soon as one team leads by more than 34 points. Penalties, VAR and the overtime shootout are not played (a tie stays a draw).<\/p><\/details>\n          <details class=\"n-h\"><summary>How online play works<\/summary><p class=\"note\">The host gets a room code and shares it. Everyone opens this same page, types the code and joins; players are balanced over Red and Blue automatically and can switch team in the Team box (not while a round is running, max 5 per team). Up to 10 players in total. Your name is sent when you join. Hit someone on the other team to score. The host's field is sent to everyone automatically. The host's device relays all traffic, so the host should have a good connection.<\/p><\/details>\n          <details class=\"n-h\"><summary>Spectator mode<\/summary><p class=\"note\">Press the \ud83d\udc41 Spectator button in the top bar before or after you join; between matches you can switch between spectator and player at any time. A spectator does not play and belongs to no team (max 4 spectators). The spectator watches the whole field from above and can drag on the ground to draw marks and arrows (tap once for a dot), which is handy for coaching. In the toolbar the spectator chooses who sees the marks: Red, Blue or both teams (spectators always see everything). The marks appear live on the floor, in every game mode. Undo removes your last mark; Clear removes the marks drawn for the selected audience (Both teams = everything); they are also cleared when a new match starts. Switch \"\u270f Draw\" off to move the camera (drag = rotate, right mouse \/ Shift = pan).<\/p><\/details>\n        <\/details>\n      <\/details>\n\n      <!-- BUILD -->\n      <!-- STRATEGY BOARD -->\n      <div class=\"p-strat\" hidden>\n        <details class=\"p-sec p-stb\" data-sec=\"strat\" open>\n          <summary>\ud83d\udccb Strategy board<\/summary>\n          <h4>Steps<\/h4>\n          <div class=\"st-steps\"><\/div>\n          <div class=\"row\">\n            <label>Step name <input type=\"text\" class=\"st-nm\" maxlength=\"40\" placeholder=\"e.g. Break-out\"><\/label>\n          <\/div>\n          <div class=\"row\">\n            <button class=\"st-new accent\" title=\"Add a step where every player starts at the end of their path\">\u2795 Next step<\/button>\n            <button class=\"st-dup\" title=\"Copy this step\">\u29c9 Duplicate<\/button>\n            <button class=\"st-del danger\">\ud83d\uddd1 Delete step<\/button>\n          <\/div>\n          <h4>Tools<\/h4>\n          <div class=\"row st-tools\" style=\"margin-top:0\">\n            <button data-t=\"sel\" class=\"on\" title=\"Select and move things\">\u2196 Move<\/button>\n            <button data-t=\"red\" title=\"Place a Red player\">\ud83d\udd34 Red player<\/button>\n            <button data-t=\"blue\" title=\"Place a Blue player\">\ud83d\udd35 Blue player<\/button>\n            <button data-t=\"path\" title=\"Add path points to the selected player\">\u27b0 Path<\/button>\n            <button data-t=\"line\" title=\"Draw a shot line \/ lane\">\u2796 Shot line<\/button>\n            <button data-t=\"note\" title=\"Place a text note\">\ud83c\udd63 Note<\/button>\n          <\/div>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"st-dash\"> Draw the next path legs dashed (crouch \/ slow)<\/label>\n          <div class=\"st-edit\" hidden>\n            <h4 class=\"st-eh\">Selected<\/h4>\n            <div class=\"row\" style=\"margin-top:0\">\n              <label class=\"st-txl\"><span class=\"st-txn\">Name<\/span> <input type=\"text\" class=\"st-tx\" maxlength=\"40\"><\/label>\n              <label class=\"st-numl\">Number <select class=\"st-num\"><\/select><\/label>\n            <\/div>\n            <div class=\"row\"><span class=\"fc-sw st-sw\"><\/span><\/div>\n            <label class=\"chk st-dshl\"><input type=\"checkbox\" class=\"st-dsh\"> <span class=\"st-dshn\">Dashed<\/span><\/label>\n            <div class=\"row\">\n              <button class=\"st-last\">\u21b6 Remove last point<\/button>\n              <button class=\"st-clr\">Clear path<\/button>\n              <button class=\"st-rm danger\">Delete<\/button>\n            <\/div>\n          <\/div>\n          <h4>Show<\/h4>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"st-sr\" checked> Red team<\/label>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"st-sb\" checked> Blue team<\/label>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"st-names\" checked> Player names<\/label>\n          <h4>Share<\/h4>\n          <div class=\"row\" style=\"margin-top:0\">\n            <button class=\"st-img\">\ud83d\udcf7 Save picture<\/button>\n            <button class=\"st-copy\">Copy strategy<\/button>\n            <button class=\"st-paste\">Paste strategy<\/button>\n            <button class=\"st-cstep danger\">Clear this step<\/button>\n          <\/div>\n          <p class=\"note\">Tip: switch on <b>Show names<\/b> in the top bar to see the bunker names. The strategy is saved with the field (also when you download or save the field). Arrow keys switch steps, Delete removes the selected item. Shift or right mouse button turns the view.<\/p>\n        <\/details>\n      <\/div>\n\n      <div class=\"p-build\" hidden>\n        <p class=\"note f-locknote\" hidden>\ud83d\udd12 The layout is locked. You can still select bunkers, rename them and change their colour. Unlock to place, move, resize or delete bunkers, or to change the start zones.<\/p>\n\n        <details class=\"p-sec\" data-sec=\"bunk\" open>\n          <summary>\ud83e\uddf1 Bunkers<\/summary>\n          <div class=\"row\" style=\"margin-top:0\"><button class=\"f-blank danger\" title=\"Remove every bunker and start from a blank field\">\ud83d\uddd1 Clear all bunkers \u00b7 blank field<\/button><\/div>\n          <h4>Place shape<\/h4>\n          <div class=\"f-types\"><\/div>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"f-mir\" checked> Mirror to other half<\/label>\n\n          <div class=\"f-sel\" hidden>\n            <h4>Selected: <span class=\"f-selt\"><\/span><\/h4>\n            <input type=\"text\" class=\"f-name\" maxlength=\"30\" placeholder=\"Type your own name, e.g. 50 or Home Snake\">\n            <div class=\"fc-row s-colrow\">\n              <label style=\"margin:0\">Colour<\/label>\n              <input type=\"color\" class=\"fc-pick s-col\" value=\"#ffffff\" title=\"Pick any colour\">\n              <span class=\"fc-sw s-sw\"><\/span>\n            <\/div>\n            <div class=\"row\">\n              <button class=\"s-coldef\">Default colour<\/button>\n              <button class=\"s-colall\">Apply to all <span class=\"s-coltype\">of this shape<\/span><\/button>\n            <\/div>\n            <div class=\"row dims\">\n              <label>Length (m) <input type=\"number\" class=\"s-l\" min=\".05\" max=\"30\" step=\".05\"><\/label>\n              <label>Width (m) <input type=\"number\" class=\"s-b\" min=\".05\" max=\"30\" step=\".05\"><\/label>\n              <label>Height (m) <input type=\"number\" class=\"s-h\" min=\".05\" max=\"30\" step=\".05\"><\/label>\n            <\/div>\n            <div class=\"row s-whrow\" style=\"display:none\"><label>Window height (m) <input type=\"number\" class=\"s-wh\" min=\".1\" max=\"3\" step=\".05\"><\/label><label>Window width (m) <input type=\"number\" class=\"s-ww\" min=\".2\" max=\"6\" step=\".05\"><\/label><label>Window position: height above ground (m) <input type=\"number\" class=\"s-wy\" min=\"0\" max=\"3\" step=\".05\"><\/label><button class=\"s-whauto\">Auto<\/button><\/div>\n            <div class=\"row s-throw\"><label>Wall thickness (m) <input type=\"number\" class=\"s-th\" min=\".05\" max=\"3\" step=\".05\"><\/label><button class=\"s-thauto\">Auto<\/button><\/div>\n            <div class=\"row\"><button class=\"s-reset\">Default size<\/button><\/div>\n            <p class=\"note s-thnote\">Wall thickness (Cross, Arrow, Staple, Wing) is separate from the size, so a big bunker can keep thin walls. Auto = thickness scales with the size.<\/p>\n            <p class=\"note\">Length is the long side at 0\u00b0 rotation. For an Arrow, length and width are linked (they scale together) so the 90\u00b0 angle always stays correct; height can be set separately.<\/p>\n            <div class=\"row\">\n              <button class=\"f-rl\">\u21ba Rotate<\/button>\n              <button class=\"f-rr\">\u21bb Rotate<\/button>\n              <button class=\"f-dup\">\u29c9 Duplicate<\/button>\n              <button class=\"f-del danger\">Remove<\/button>\n            <\/div>\n          <\/div>\n\n          <h4>All bunkers<\/h4>\n          <div class=\"f-list\"><\/div>\n        <\/details>\n\n        <details class=\"p-sec\" data-sec=\"field\">\n          <summary>\ud83d\udcd0 Field size, colour &amp; generate<\/summary>\n          <div class=\"row\">\n            <label>Width (m) <input type=\"number\" class=\"f-w\" min=\"10\" max=\"60\" value=\"30\"><\/label>\n            <label>Length (m) <input type=\"number\" class=\"f-l\" min=\"15\" max=\"80\" value=\"40\"><\/label>\n          <\/div>\n          <div class=\"row\">\n            <label>Bunkers per half <input type=\"number\" class=\"f-cnt\" min=\"2\" max=\"12\" value=\"5\"><\/label>\n          <\/div>\n          <h4>Hall<\/h4>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"f-indoor\"> \ud83c\udfed Indoor hall (walls, roof and TL lights)<\/label>\n          <label>Lighting: <b class=\"f-ltv\">100%<\/b><input type=\"range\" class=\"f-lt\" min=\"10\" max=\"150\" step=\"10\" value=\"100\"><\/label>\n          <h4>Field colour<\/h4>\n          <div class=\"fc-row\">\n            <input type=\"color\" class=\"fc-pick ff-pick\" value=\"#5aa84a\" title=\"Pick any colour\">\n            <span class=\"fc-sw ff-sw\"><\/span>\n            <button class=\"fc-reset\" title=\"Back to green\">Green<\/button>\n          <\/div>\n          <label>Ground brightness: <b class=\"fb-v\">100%<\/b><input type=\"range\" class=\"fb-size\" min=\"30\" max=\"200\" step=\"5\" value=\"100\"><\/label>\n          <label class=\"ft-row\" hidden>Texture tile size: <b class=\"ft-v\">8 m<\/b><input type=\"range\" class=\"ft-size\" min=\"1\" max=\"30\" step=\"1\" value=\"8\"><\/label>\n          <div class=\"row\">\n            <button class=\"f-apply\">Apply size<\/button>\n            <button class=\"f-gen accent\">Generate field<\/button>\n            <button class=\"f-clear\">Clear all<\/button>\n          <\/div>\n        <\/details>\n\n        <details class=\"p-sec\" data-sec=\"zones\">\n          <summary>\ud83d\udfe5\ud83d\udfe6 Start zones<\/summary>\n          <div class=\"zt red\">Red<\/div>\n          <div class=\"row dims\">\n            <label>Width (m) <input type=\"number\" class=\"z-rw\" min=\"1\" step=\".5\"><\/label>\n            <label>Depth (m) <input type=\"number\" class=\"z-rd\" min=\".5\" step=\".5\"><\/label>\n            <label>Offset (m) <input type=\"number\" class=\"z-rx\" step=\".5\"><\/label>\n            <label>Height (m) <input type=\"number\" class=\"z-rh\" min=\".05\" max=\"1\" step=\".05\"><\/label>\n          <\/div>\n          <div class=\"zt blue\">Blue<\/div>\n          <div class=\"row dims\">\n            <label>Width (m) <input type=\"number\" class=\"z-bw\" min=\"1\" step=\".5\"><\/label>\n            <label>Depth (m) <input type=\"number\" class=\"z-bd\" min=\".5\" step=\".5\"><\/label>\n            <label>Offset (m) <input type=\"number\" class=\"z-bx\" step=\".5\"><\/label>\n            <label>Height (m) <input type=\"number\" class=\"z-bh\" min=\".05\" max=\"1\" step=\".05\"><\/label>\n          <\/div>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"z-link\" checked> Link red &amp; blue (same values)<\/label>\n          <p class=\"note\">The side against the wall stays fixed. Offset moves the zone sideways (0 = centered). Height raises the zone like a platform (0.05 = flat). Players cannot walk onto a start zone; it is solid like a bunker. Where there is no colored zone, bunkers can be placed right up to the wall.<\/p>\n        <\/details>\n\n        <details class=\"p-sec\" data-sec=\"image\">\n          <summary>\ud83d\uddbc\ufe0f Field image<\/summary>\n          <input type=\"file\" class=\"i-file\" accept=\"image\/*\" hidden>\n          <div class=\"row\">\n            <button class=\"i-pick accent\">Choose image<\/button>\n            <button class=\"i-rm danger\">Remove<\/button>\n          <\/div>\n          <label class=\"chk\"><input type=\"checkbox\" class=\"i-show\" checked disabled> Show image on the floor<\/label>\n          <label style=\"margin-top:8px\">Opacity <input type=\"range\" class=\"i-op\" min=\"15\" max=\"100\" value=\"85\" disabled><\/label>\n          <div class=\"row\">\n            <button class=\"i-rot\">\u21bb Rotate 90\u00b0<\/button>\n            <button class=\"i-fh\">\u21cb Flip horizontal<\/button>\n            <button class=\"i-fv\">\u21c5 Flip vertical<\/button>\n          <\/div>\n          <div class=\"row\">\n            <button class=\"i-fit\">Match field proportions<\/button>\n          <\/div>\n          <p class=\"note\">Upload a top-down image. Enter the real field dimensions in \"Field size\" so the field is to scale. The image is stretched over the whole field; use \"Match field proportions\" to get the proportions right. Rotate or flip it until it lines up. Turn the image off when you are done.<\/p>\n        <\/details>\n\n      <\/div>\n\n      <!-- PRACTICE -->\n      <div class=\"p-quiz\" hidden>\n        <h4>Practice mode<\/h4>\n        <select class=\"q-type\">\n          <option value=\"find\">Where is \u2026? (click the bunker)<\/option>\n          <option value=\"name\">What is this bunker called? (pick the name)<\/option>\n        <\/select>\n        <div class=\"row\"><button class=\"q-start accent\">Start<\/button><\/div>\n        <label class=\"chk q-grp-l\"><input type=\"checkbox\" class=\"q-grp\" checked> Practice together with everyone online (host only)<\/label>\n        <div class=\"q-score\">Score 0\/0<\/div>\n        <div class=\"q-board\"><\/div>\n        <p class=\"note q-onl\">Online: host a game first (Play tab), then switch to Practice and press Start. Everyone in the room joins automatically, gets the same question and sees a live scoreboard. The next question comes when everybody has answered (or after 25 s).<\/p>\n        <p class=\"note\">The quiz only uses bunkers that you gave a custom name under \"Build\". Tip: first look at the field with names on, from \"Red view\" or \"Blue view\". Then turn the names off and start the quiz.<\/p>\n      <\/div>\n      <\/div>\n    <\/div>\n  <\/div>\n  <div class=\"pbf-dlg\" hidden>\n    <div class=\"dlg-card\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Settings\">\n      <div class=\"dlg-h\"><b>\u2699 Settings<\/b><button class=\"dlg-x\" aria-label=\"Close\">\u2715<\/button><\/div>\n      <div class=\"dlg-tabs\"><button data-dt=\"set\" class=\"on\">Player<\/button><button data-dt=\"pad\">Keys &amp; controller<\/button><\/div>\n      <div class=\"dlg-b\">\n      <div class=\"p-pad\" data-dp=\"pad\" hidden>\n        \n        <div class=\"note\"><b>Keyboard<\/b>: click a key to change it, then press the new key (Esc cancels). Keys <b>1-5<\/b> (weapon \/ start spot), <b>Tab<\/b> and the arrow keys stay fixed.<\/div>\n        <div class=\"kb-list\"><\/div>\n        <div class=\"row\"><button class=\"kb-reset\">Reset keys to default<\/button><\/div>\n        <p class=\"note\"><b>Controller<\/b><\/p>\n        <p class=\"note gp-st\">\ud83c\udfae No controller detected. Connect a PlayStation or Xbox controller (USB or Bluetooth) and press any button.<\/p>\n        <p class=\"note\"><b>Start \/ Options<\/b>: tap = start walking, hold = stop walking<br>\n        <b>Left stick<\/b>: move (push fully or click <b>L3<\/b> to sprint)<br>\n        <b>Right stick<\/b>: look<br>\n        <b>R2 \/ RT<\/b>: shoot<br>\n        <b>R3<\/b> (press right stick): prone &nbsp; <b>A \/ \u2715<\/b>: jump (stands you up when prone) &nbsp; <b>B \/ \u25cb<\/b>: crouch (while sprinting: slide)<br>\n        <b>X \/ \u25a1<\/b>: hand over \/ drop the flag (Flag mode)<br>\n        <b>Y \/ \u25b3<\/b> or <b>D-pad \u25c0 \u25b6<\/b>: switch weapon<br>\n        <b>R3<\/b> \/ <b>X<\/b> (between rounds): spectate the next match \/ switch team<br>\n        <b>L1 \/ LB<\/b> and <b>R1 \/ RB<\/b>: peek left \/ right<br>\n        <b>D-pad up<\/b>: other hand &nbsp; <b>D-pad down<\/b>: clear hit marks<br>\n        <b>Back \/ Select<\/b>: hold = scoreboard<br>\n        The controller vibrates when you are hit. Use a controller with the standard layout; most Xbox and PlayStation controllers work in Chrome, Edge and Firefox.<\/p>\n      <\/div>\n\n      <div class=\"p-set\" data-dp=\"set\">\n        \n        <p class=\"note\" style=\"margin-top:0\">These settings are saved in your own browser. Only your weapon colours are shown to other players.<\/p>\n        <label title=\"Field of view when walking (horizontal). Lower = things look closer, higher = wider view. Tune it until the far bunker looks as big as in real life.\">Field of view: <b class=\"f-fovv\">80\u00b0<\/b><input type=\"range\" class=\"f-fov\" min=\"50\" max=\"100\" step=\"1\" value=\"80\"><\/label>\n        <label>Name size when walking: <b class=\"f-lsv\">50%<\/b><input type=\"range\" class=\"f-ls\" min=\"15\" max=\"150\" step=\"5\" value=\"50\"><\/label>\n        <label title=\"How fast the view turns with the mouse (1:1 = your normal speed)\">Mouse sensitivity: <b class=\"f-sensv\">100%<\/b><input type=\"range\" class=\"f-sens\" min=\"20\" max=\"300\" step=\"5\" value=\"100\"><\/label>\n        <label class=\"chk\"><input type=\"checkbox\" class=\"f-touch\"> Touch controls<\/label>\n        <label class=\"chk\"><input type=\"checkbox\" class=\"f-splat\" checked> Paint splats<\/label>\n        <label class=\"chk\" title=\"Little metal strips on the edges of the bunkers, like the real ones\"><input type=\"checkbox\" class=\"f-edge\" checked> Metal edges<\/label>\n        <h4>Weapon colours<\/h4>\n        <div class=\"wc-grid\">\n          <label>Rifle body <input type=\"color\" class=\"wc-pick\" data-k=\"rb\"><\/label>\n          <label>Grip &amp; tank <input type=\"color\" class=\"wc-pick\" data-k=\"rg\"><\/label>\n          <label>Magazine <input type=\"color\" class=\"wc-pick\" data-k=\"rm\"><\/label>\n          <label>Trigger <input type=\"color\" class=\"wc-pick\" data-k=\"rt\"><\/label>\n          <label>Accents <input type=\"color\" class=\"wc-pick\" data-k=\"ra\"><\/label>\n          <label>Pistol slide <input type=\"color\" class=\"wc-pick\" data-k=\"ps\"><\/label>\n          <label>Pistol frame <input type=\"color\" class=\"wc-pick\" data-k=\"pf\"><\/label>\n        <\/div>\n        <div class=\"row\"><button class=\"wc-reset\">Reset weapon colours<\/button><\/div>\n      <\/div>\n\n\n        <div class=\"row dlg-tour\"><button class=\"tour-again\">\u25b6 Replay the tutorial<\/button><\/div>\n      <\/div>\n    <\/div>\n  <\/div>\n  <div class=\"pbf-tour\" hidden>\n    <div class=\"tr-spot\"><\/div>\n    <div class=\"tr-card\" role=\"dialog\" aria-live=\"polite\">\n      <div class=\"tr-n\"><\/div>\n      <h3 class=\"tr-t\"><\/h3>\n      <div class=\"tr-x\"><\/div>\n      <div class=\"tr-dots\"><\/div>\n      <div class=\"tr-b\"><button class=\"tr-skip\">Skip tour<\/button><span class=\"tr-sp\"><\/span><button class=\"tr-back\">\u2039 Back<\/button><button class=\"tr-next accent\">Next \u203a<\/button><\/div>\n    <\/div>\n  <\/div>\n<\/div>\n\n<style>\n#pbf-app{--bg:#10151b;--pn:#18202a;--pn2:#202a36;--ln:#2d3947;--tx:#e9eef4;--mut:#8fa0b3;--ac:#ff7a1a;--ok:#2ecc71;--bad:#e5484d;\n  font-family:system-ui,-apple-system,\"Segoe UI\",Roboto,sans-serif;color:var(--tx);background:var(--bg);border:1px solid var(--ln);border-radius:12px;overflow:hidden;line-height:1.35;font-size:14px}\n#pbf-app *{box-sizing:border-box}\n#pbf-app [hidden]{display:none!important}\n#pbf-app h4{margin:16px 0 8px;font-size:13px;font-weight:700;color:var(--mut);letter-spacing:.02em;text-transform:none}\n#pbf-app h4:first-child{margin-top:0}\n#pbf-app button{font:inherit;font-size:13px;background:var(--pn2);color:var(--tx);border:1px solid var(--ln);border-radius:8px;padding:8px 11px;cursor:pointer;text-transform:none;letter-spacing:normal;box-shadow:none;line-height:1.2;transition:background .15s,border-color .15s}\n#pbf-app button:hover{background:#2a3644;color:var(--tx);border-color:#415165}\n#pbf-app button.on,#pbf-app button.accent{background:var(--ac);border-color:var(--ac);color:#17110a;font-weight:700}\n#pbf-app button.on:hover,#pbf-app button.accent:hover{background:#ff8f3d;color:#17110a}\n#pbf-app button.danger{color:#ff8e91;border-color:#5a2c30}\n#pbf-app button:focus-visible,#pbf-app input:focus-visible,#pbf-app select:focus-visible{outline:2px solid var(--ac);outline-offset:2px}\n#pbf-app input[type=text],#pbf-app input[type=number],#pbf-app select{width:100%;font:inherit;background:var(--pn);color:var(--tx);border:1px solid var(--ln);border-radius:8px;padding:8px 10px;margin:0}\n#pbf-app label{display:block;font-size:12px;color:var(--mut)}\n#pbf-app label.chk{display:flex;align-items:center;gap:6px;color:var(--tx);font-size:13px;margin-top:8px}\n#pbf-app label.chk input{width:auto;margin:0}\n#pbf-app .row{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}\n#pbf-app .row>label{flex:1;min-width:110px}\n#pbf-app .row>label input{margin-top:4px}\n#pbf-app .note{color:var(--mut);font-size:12px;margin:8px 0 0}\n#pbf-app .row.dims>label{min-width:70px}\n#pbf-app input[type=range]{width:100%;margin:6px 0 0;accent-color:var(--ac)}\n#pbf-app button:disabled{opacity:.45;cursor:not-allowed}\n#pbf-app input:disabled{opacity:.5;cursor:not-allowed}\n#pbf-app details.m-off{opacity:.45;cursor:not-allowed}\n#pbf-app .kb-list{display:grid;grid-template-columns:1fr auto;gap:5px 10px;align-items:center;margin:6px 0}\n#pbf-app .kb-list span{font-size:12.5px}\n#pbf-app .kb-list button{min-width:84px;padding:5px 8px}\n#pbf-app .kb-list button.wait{outline:2px solid #ffd84a}\n#pbf-app .fc-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n#pbf-app input.fc-pick{width:44px;height:34px;padding:2px;border-radius:8px;cursor:pointer}\n#pbf-app .fc-sw{display:flex;gap:6px;flex-wrap:wrap}\n#pbf-app .fc-sw button.tx{background-size:cover;background-position:center;border-radius:6px}\n#pbf-app .pbf-splash{position:fixed;inset:0;z-index:2147483000;background:#000;display:flex;align-items:center;justify-content:center;animation:pbfSplash .5s ease 2.5s forwards}\n#pbf-app .pbf-splash img{max-width:70vw;max-height:70vh;width:auto;height:auto;object-fit:contain}\n@keyframes pbfSplash{to{opacity:0;visibility:hidden;pointer-events:none}}\n#pbf-app .wc-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;margin:6px 0}\n#pbf-app .wc-grid label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0}\n#pbf-app input.wc-pick{width:44px;height:30px;padding:2px;border-radius:8px;cursor:pointer}\n#pbf-app .fc-sw button{width:26px;height:26px;padding:0;border-radius:50%;border:2px solid rgba(255,255,255,.4)}\n#pbf-app .fc-sw button.sel{outline:2px solid #fff;outline-offset:2px}\n#pbf-app .m-pre,#pbf-app .m-list{display:flex;flex-direction:column;gap:6px}\n#pbf-app .m-list{margin-top:8px}\n#pbf-app .m-item{display:flex;align-items:center;gap:8px;background:var(--pn2);border:1px solid var(--ln);border-radius:8px;padding:6px 8px}\n#pbf-app .m-item .m-t{flex:1;min-width:0}\n#pbf-app .m-item .m-t b{display:block;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n#pbf-app .m-item .m-t span{font-size:11px;color:var(--mut)}\n#pbf-app .m-item button{padding:6px 10px;font-size:12px}\n#pbf-app .f-locknote{margin:0 0 4px;color:var(--ac)}\n#pbf-app .zt{margin-top:8px;font-size:12px;font-weight:700}\n#pbf-app .zt.red{color:#ff8e91}\n#pbf-app .zt.blue{color:#7fb0ff}\n.pbf-group{display:flex;gap:6px;flex-wrap:wrap;align-items:center}\n#pbf-app .pbf-vis{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mut);padding:4px 10px;border:1px solid var(--ln);border-radius:999px;margin-top:8px;white-space:nowrap}\n#pbf-app .pbf-vis::before{content:\"\";width:7px;height:7px;border-radius:50%;background:var(--ok);display:inline-block}\n#pbf-app .pbf-vis b{color:var(--tx)}\n.pbf-main{display:grid;grid-template-columns:1fr}\n.pbf-stage{position:relative;height:72vh;min-height:420px;max-height:900px;background:#9ec9e8}\n.pbf-stage canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}\n.pbf-hud{position:absolute;left:10px;right:10px;top:10px;pointer-events:none;color:#fff;background:rgba(10,14,20,.78);padding:8px 12px;border-radius:8px;font-size:14px;max-width:max-content;transition:opacity .6s}\n.pbf-hud.off,.pbf-hud:empty{opacity:0}\n@media(max-width:700px){.pbf-hud{left:8px;right:auto;top:8px;max-width:calc(100% - 16px);padding:5px 9px;font-size:12px;line-height:1.3;border-radius:7px}}\n.pbf-hud b{color:var(--ac)}\n.pbf-xh{position:absolute;left:50%;top:50%;width:4px;height:4px;margin:-2px 0 0 -2px;pointer-events:none;border-radius:50%;background:#fff;filter:drop-shadow(0 0 1.5px #000)}\n.pbf-opts{position:absolute;left:10px;right:10px;bottom:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px}\n.pbf-opts:empty{display:none}\n#pbf-app .pbf-opts button{padding:14px 8px;font-size:15px;background:rgba(16,21,27,.92)}\n#pbf-app .pbf-opts button.ok{background:var(--ok);border-color:var(--ok);color:#06210f}\n#pbf-app .pbf-opts button.bad{background:var(--bad);border-color:var(--bad);color:#fff}\n.pbf-side{padding:12px;background:var(--bg);border-top:1px solid var(--ln)}\n.f-types{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}\n.f-list{display:flex;flex-wrap:wrap;gap:6px}\n#pbf-app .f-list button{padding:5px 9px;font-size:12px}\n.q-score{margin-top:12px;font-size:18px;font-weight:700}\n.q-board{margin-top:6px;display:grid;gap:3px;font-size:14px}.q-board div{display:flex;justify-content:space-between;gap:12px;padding:3px 8px;border-radius:6px;background:rgba(255,255,255,.07)}.q-board .me{outline:1px solid #2ecc71}\n#pbf-app details.p-net,#pbf-app details.p-sec{margin:0 0 8px;border:1px solid var(--ln);border-radius:8px;padding:8px 10px;background:var(--pn)}\n#pbf-app details.p-net>summary,#pbf-app details.p-sec>summary{cursor:pointer;font-weight:700;font-size:13px}\n#pbf-app details[open]>summary{margin-bottom:6px}\n#pbf-app details.p-sec>summary+h4{margin-top:6px}\n#pbf-app .n-status{color:var(--tx)}\n.pbf-net{position:absolute;right:10px;top:10px;pointer-events:none;color:#fff;background:rgba(10,14,20,.78);padding:6px 10px;border-radius:8px;font-size:13px}\n.pbf-hit{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .6s;background:radial-gradient(ellipse at center,rgba(229,72,77,0) 35%,rgba(229,72,77,.75) 100%)}\n.pbf-hit.on{opacity:1;transition:none}\n#pbf-app .pbf-stbar{position:absolute;left:50%;bottom:calc(14px + env(safe-area-inset-bottom,0px));transform:translateX(-50%);display:flex;align-items:center;gap:10px;background:rgba(10,14,20,.86);color:#fff;padding:6px 10px;border-radius:999px;max-width:94%;z-index:5}\n#pbf-app .pbf-stbar b{font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw}\n#pbf-app .pbf-stbar button{width:38px;height:38px;border-radius:50%;padding:0;font-size:22px;line-height:1}\n#pbf-app .st-steps{display:flex;gap:6px;flex-wrap:wrap}\n#pbf-app .st-steps button{min-width:34px}\n#pbf-app .st-tools button{flex:1 1 30%}\n#pbf-app .st-edit{margin-top:10px;padding:8px;border:1px solid var(--ln);border-radius:8px}\n.pbf-watch{position:absolute;left:50%;bottom:calc(22px + env(safe-area-inset-bottom,0px));transform:translateX(-50%);max-width:92%;padding:8px 16px;border-radius:999px;background:rgba(10,14,20,.78);color:#fff;font:600 14px\/1.3 system-ui,sans-serif;text-align:center;pointer-events:none;border:1px solid rgba(255,255,255,.25);z-index:6}\n.pbf-watch small{display:block;font-weight:400;opacity:.8;font-size:11.5px}\n.pbf-count{position:absolute;left:0;right:0;top:24%;text-align:center;font-size:84px;font-weight:800;color:#fff;text-shadow:0 3px 14px rgba(0,0,0,.8);pointer-events:none;line-height:1}\n.pbf-count.sm{font-size:30px}\n.pbf-ammo{position:absolute;right:10px;bottom:10px;pointer-events:none;color:#fff;background:rgba(10,14,20,.78);padding:6px 12px;border-radius:8px;font-size:16px;font-weight:700;font-variant-numeric:tabular-nums}\n.pbf-stage.touch-on .pbf-ammo{right:34px;bottom:268px}\n.pbf-stage:fullscreen{width:100%;height:100%;max-height:none}\n.pbf-stage:-webkit-full-screen{width:100%;height:100%;max-height:none}\n.pbf-stage.pseudo-fs{position:fixed;top:0;left:0;right:0;bottom:0;width:100%;height:100%;max-height:none;z-index:2147483000}\n\n#pbf-app .n-sec{margin-top:8px;padding:8px 10px;background:var(--bg);border:1px solid var(--ln);border-radius:8px}\n#pbf-app .n-sec h5{margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}\n#pbf-app .n-sec .row{margin-top:0}\n#pbf-app details.n-help{margin-top:8px;border:1px solid var(--ln);border-radius:8px;padding:6px 10px;background:var(--bg)}\n#pbf-app details.n-help>summary{cursor:pointer;font-weight:700;font-size:12px}\n#pbf-app details.n-h{margin-top:6px;border-top:1px solid var(--ln);padding-top:6px}\n#pbf-app details.n-h>summary{cursor:pointer;font-size:12px;color:var(--tx)}\n#pbf-app details.n-h .note{margin:6px 0 0}\n#pbf-app .n-servers{margin-top:6px;display:grid;gap:6px}\n#pbf-app .n-servers:empty{display:none}\n#pbf-app .n-servers .sv{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;background:var(--pn2);border:1px solid var(--ln);border-radius:8px;font-size:12px}\n#pbf-app .n-servers .sv b{display:block;font-size:13px}\n#pbf-app .n-servers .sv span{color:var(--mut)}\n#pbf-app .n-servers .sv button{flex:none}\n#pbf-app .n-servers .sv-none{font-size:12px;color:var(--mut)}\n#pbf-app .n-roster{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;font-size:12px;line-height:1.5}\n#pbf-app .n-roster:empty{display:none}\n#pbf-app .n-roster b{display:block;margin-bottom:2px}\n\/* touch controls (virtual joysticks) *\/\n.pbf-touch{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}\n.pbf-touch>*{pointer-events:auto;touch-action:none}\n.pbf-touch .tj{position:absolute;bottom:calc(18px + env(safe-area-inset-bottom,0px));width:140px;height:140px;border-radius:50%;background:rgba(16,21,27,.35);border:2px solid rgba(255,255,255,.35)}\n.pbf-touch .tj-l{left:calc(18px + env(safe-area-inset-left,0px))}\n.pbf-touch .tj-r{right:calc(18px + env(safe-area-inset-right,0px))}\n.pbf-touch .tj-k{position:absolute;left:50%;top:50%;width:58px;height:58px;margin:-29px 0 0 -29px;border-radius:50%;background:rgba(255,255,255,.55);border:2px solid rgba(255,255,255,.85);pointer-events:none}\n#pbf-app .pbf-touch .tb{min-width:46px;height:46px;padding:0 12px;border-radius:23px;background:rgba(16,21,27,.6);color:#fff;border:2px solid rgba(255,255,255,.4);font-size:13px;font-weight:700;touch-action:none;-webkit-tap-highlight-color:transparent}\n#pbf-app .pbf-touch .tb.on,#pbf-app .pbf-touch .tb.down{background:var(--ac);border-color:var(--ac);color:#17110a}\n#pbf-app .pbf-touch .tb-fire{position:absolute;right:calc(34px + env(safe-area-inset-right,0px));bottom:calc(176px + env(safe-area-inset-bottom,0px));width:84px;height:84px;border-radius:50%;background:rgba(229,72,77,.75);border-color:rgba(255,255,255,.7);font-size:15px}\n#pbf-app .pbf-touch .tb-fire.down{background:#e5484d;color:#fff;transform:scale(.94)}\n.pbf-touch .tb-row{position:absolute;left:calc(14px + env(safe-area-inset-left,0px));bottom:calc(172px + env(safe-area-inset-bottom,0px));display:flex;gap:8px}\n.pbf-touch .tb-top{position:absolute;right:10px;top:46px;display:flex;gap:8px}\n#pbf-app .pbf-touch .tb-j{position:absolute;right:calc(128px + env(safe-area-inset-right,0px));bottom:calc(196px + env(safe-area-inset-bottom,0px))}\n.pbf-stage.touch-on .pbf-opts{bottom:230px}\n\n@media(min-width:900px){\n  .pbf-main{grid-template-columns:1fr 350px}\n  #pbf-app{--sh:clamp(560px,calc(100vh - 150px),1100px)}\n  .pbf-stage{height:var(--sh);max-height:none}\n  .pbf-side{border-top:0;border-left:1px solid var(--ln);max-height:var(--sh);overflow:auto}\n}\n@media(prefers-reduced-motion:reduce){#pbf-app *{transition:none!important}}\n\n\/* ===== new layout: mode switcher, contextual toolbar, tabbed side panel, settings window, guided tour ===== *\/\n#pbf-app{--hd:#0d1218;--bar:#1a2330}\n#pbf-app.m-play{--ac:#ff7a1a}\n#pbf-app.m-build{--ac:#3d9bff}\n#pbf-app.m-quiz{--ac:#2ecc71}\n#pbf-app.m-strat{--ac:#b583ff}\n#pbf-app button.on:hover,#pbf-app button.accent:hover{background:var(--ac);filter:brightness(1.12)}\n.pbf-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:10px 12px 0;background:var(--hd);border-bottom:1px solid var(--ln)}\n.pbf-modes{display:flex;align-items:flex-end;gap:4px;min-width:0;max-width:100%;overflow-x:auto;scrollbar-width:none}\n.pbf-modes::-webkit-scrollbar{display:none}\n.pbf-sep{width:1px;height:30px;align-self:center;background:var(--ln);margin:0 8px 4px}\n#pbf-app .pbf-modes button{--mc:var(--mut);display:flex;align-items:center;gap:10px;flex:0 0 auto;padding:10px 18px 11px;border:1px solid transparent;border-bottom:0;border-radius:12px 12px 0 0;background:transparent;color:var(--mut);text-align:left;font-size:15px;line-height:1.15;transition:background .15s,color .15s}\n#pbf-app .pbf-modes button[data-mode=play]{--mc:#ff7a1a}\n#pbf-app .pbf-modes button[data-mode=build]{--mc:#3d9bff}\n#pbf-app .pbf-modes button[data-mode=quiz]{--mc:#2ecc71}\n#pbf-app .pbf-modes button[data-mode=strat]{--mc:#b583ff}\n#pbf-app .pbf-modes button i{font-style:normal;font-size:22px;line-height:1;filter:grayscale(.85) opacity(.75);transition:filter .15s}\n#pbf-app .pbf-modes button span{display:flex;flex-direction:column;gap:2px}\n#pbf-app .pbf-modes button b{font-size:15px;font-weight:700;color:inherit}\n#pbf-app .pbf-modes button small{font-size:11px;font-weight:500;color:var(--mut);white-space:nowrap}\n#pbf-app .pbf-modes button:hover{background:rgba(255,255,255,.05);color:var(--tx);border-color:transparent}\n#pbf-app .pbf-modes button.on{background:var(--bar);color:var(--tx);border-color:var(--ln);box-shadow:inset 0 3px 0 var(--mc);margin-bottom:-1px;padding-bottom:12px}\n#pbf-app .pbf-modes button.on i{filter:none}\n#pbf-app .pbf-modes button.on small{color:var(--mc)}\n#pbf-app .pbf-modes button:disabled{opacity:.4}\n.pbf-tools{position:relative;display:flex;align-items:center;gap:8px;padding-bottom:8px}\n#pbf-app .pbf-tools .pbf-vis{margin:0}\n#pbf-app button.pbf-ico{width:36px;height:36px;padding:0;border-radius:50%;font-size:17px;font-weight:700;line-height:1;display:inline-flex;align-items:center;justify-content:center}\n.pbf-help{position:absolute;right:0;top:100%;margin-top:-2px;z-index:30;min-width:240px;display:flex;flex-direction:column;gap:2px;background:var(--pn);border:1px solid var(--ln);border-radius:12px;padding:6px;box-shadow:0 14px 40px rgba(0,0,0,.5)}\n#pbf-app .pbf-help button{display:flex;flex-direction:column;align-items:flex-start;gap:1px;background:transparent;border-color:transparent;text-align:left;padding:9px 12px}\n#pbf-app .pbf-help button small{font-size:11px;color:var(--mut)}\n#pbf-app .pbf-help button:hover{background:var(--pn2);border-color:transparent}\n.pbf-bar{display:flex;align-items:center;justify-content:space-between;gap:8px 16px;flex-wrap:wrap;padding:8px 12px;background:var(--bar);border-bottom:1px solid var(--ln)}\n.bar-g{display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n.bar-g .gl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin-right:2px}\n#pbf-app .bar-g label.chk{margin:0 0 0 6px}\n#pbf-app:not(.m-play) .f-spec{display:none}\n#pbf-app.m-strat .f-walk{display:none}\n.pbf-side{padding:0;display:flex;flex-direction:column;background:var(--bg)}\n.sd-head{display:flex;align-items:center;gap:12px;padding:12px;border-top:3px solid var(--ac);border-bottom:1px solid var(--ln);background:linear-gradient(180deg,color-mix(in srgb,var(--ac) 16%,var(--pn)),var(--pn))}\n.sd-ico{flex:0 0 auto;width:40px;height:40px;border-radius:11px;background:var(--ac);color:#10151b;display:flex;align-items:center;justify-content:center;font-size:20px}\n.sd-tx{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.sd-t{font-size:16px;font-weight:800}\n.sd-s{font-size:12px;color:var(--mut);line-height:1.3}\n#pbf-app .sd-how{flex:0 0 auto;padding:6px 10px;font-size:12px;border-radius:999px}\n.sd-tabs{position:sticky;top:0;z-index:3;display:flex;flex-wrap:wrap;gap:6px;padding:10px 12px;background:var(--bg);border-bottom:1px solid var(--ln)}\n#pbf-app .sd-tab{padding:6px 11px;font-size:12.5px;border-radius:999px;background:var(--pn);border-color:var(--ln);color:var(--mut)}\n#pbf-app .sd-tab:hover{color:var(--tx)}\n#pbf-app .sd-tab.on{background:var(--ac);border-color:var(--ac);color:#10151b}\n.sd-body{padding:12px}\n#pbf-app .sd-body details.p-sec,#pbf-app .sd-body details.p-net{border:0;padding:0;background:none;margin:0}\n#pbf-app .sd-body details.p-sec>summary,#pbf-app .sd-body details.p-net>summary{display:none}\n#pbf-app .sd-body details.p-sec>summary+*,#pbf-app .sd-body details.p-net>summary+*{margin-top:0}\n#pbf-app.t-nolock .f-locknote{display:none}\n#pbf-app .sd-body .p-quiz>h4:first-child{display:none}\n#pbf-app.m-play .m-edit{display:none}\n#pbf-app .m-edit{margin-top:4px}\n.pbf-dlg{position:fixed;inset:0;z-index:2147482500;background:rgba(3,6,10,.66);display:flex;align-items:center;justify-content:center;padding:16px}\n.dlg-card{width:min(560px,100%);max-height:min(86vh,760px);display:flex;flex-direction:column;background:var(--pn);border:1px solid var(--ln);border-top:3px solid var(--ac);border-radius:14px;box-shadow:0 24px 70px rgba(0,0,0,.6)}\n.dlg-h{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 4px;font-size:16px}\n#pbf-app .dlg-x{width:34px;height:34px;padding:0;border-radius:50%}\n.dlg-tabs{display:flex;gap:6px;padding:8px 14px;border-bottom:1px solid var(--ln)}\n#pbf-app .dlg-tabs button{border-radius:999px;padding:6px 14px}\n.dlg-b{padding:12px 14px 16px;overflow:auto}\n.dlg-tour{margin-top:16px;padding-top:12px;border-top:1px solid var(--ln)}\n.pbf-tour{position:fixed;inset:0;z-index:2147482000}\n.pbf-tour.nospot{background:rgba(4,8,14,.84)}\n.pbf-tour.nospot .tr-spot{display:none}\n.tr-spot{position:fixed;border-radius:12px;box-shadow:0 0 0 9999px rgba(4,8,14,.8),0 0 0 3px var(--ac),0 0 24px 4px color-mix(in srgb,var(--ac) 55%,transparent);pointer-events:none;transition:left .25s ease,top .25s ease,width .25s ease,height .25s ease}\n.tr-card{position:fixed;width:min(370px,calc(100vw - 16px));background:var(--pn);border:1px solid var(--ln);border-top:3px solid var(--ac);border-radius:14px;padding:14px 16px 12px;box-shadow:0 18px 56px rgba(0,0,0,.6)}\n.tr-card.mid{left:50%!important;top:50%!important;transform:translate(-50%,-50%);width:min(440px,calc(100vw - 16px))}\n.tr-n{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ac)}\n.tr-t{margin:4px 0 6px;font-size:18px;line-height:1.25;color:var(--tx)}\n.tr-x{font-size:13.5px;line-height:1.5;color:#c9d4e0}\n.tr-x ul{margin:8px 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:5px}\n.tr-x li{padding-left:2px}\n.tr-x kbd{font:inherit;font-size:12px;font-weight:700;background:var(--pn2);border:1px solid var(--ln);border-bottom-width:2px;border-radius:5px;padding:0 5px;color:var(--tx)}\n.tr-dots{display:flex;gap:5px;margin:12px 0 10px}\n.tr-dots i{width:7px;height:7px;border-radius:50%;background:var(--ln)}\n.tr-dots i.on{background:var(--ac);width:18px;border-radius:4px}\n.tr-b{display:flex;align-items:center;gap:8px}\n.tr-sp{flex:1}\n#pbf-app .tr-skip{background:transparent;border-color:transparent;color:var(--mut);padding:8px 6px}\n@media(max-width:700px){\n  .pbf-modes{width:100%;overflow:visible}\n  #pbf-app .pbf-modes button{flex:1 1 0;min-width:0;justify-content:center;padding:9px 4px 10px;gap:5px}\n  #pbf-app .pbf-modes button small{display:none}\n  #pbf-app .pbf-modes button i{font-size:19px}\n  .pbf-sep{display:none}\n  .pbf-head{padding-top:8px}\n  .pbf-tools{order:-1;width:100%;justify-content:flex-end;padding-bottom:6px}\n  .tr-card:not(.mid){left:8px!important;right:8px;top:auto!important;bottom:8px;width:auto}\n}\n@media(max-width:420px){#pbf-app .pbf-modes button b{font-size:13.5px}#pbf-app .pbf-modes button i{font-size:17px}}\n@media(prefers-reduced-motion:reduce){.tr-spot{transition:none}}\n#pbf-app .pbf-coach{position:absolute;left:10px;bottom:10px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;background:rgba(10,14,20,.82);padding:6px 8px;border-radius:8px;max-width:calc(100% - 20px)}\n#pbf-app .pbf-coach .c-cols{display:flex;gap:6px}\n#pbf-app .pbf-coach .chk{margin:0;color:#fff}\n#pbf-app .pbf-coach button.c-sw{width:26px;height:26px;padding:0;border-radius:50%;border:2px solid rgba(255,255,255,.55)}\n#pbf-app .pbf-coach button.c-sw.sel{outline:2px solid #fff;outline-offset:2px}\n#pbf-app .pbf-coach select{width:auto;padding:5px 8px}\n.pbf-ws{position:absolute;left:50%;top:60%;transform:translate(-50%,-50%);z-index:7;display:flex;flex-direction:column;align-items:center;gap:8px;background:rgba(10,14,20,.9);border:1px solid #2d3947;border-radius:14px;padding:12px 14px;color:#fff;max-width:96%}\n.pbf-ws .ws-t{font-weight:800;font-size:15px}\n.pbf-ws .ws-b{display:flex;gap:10px;flex-wrap:wrap;justify-content:center}\n#pbf-app .pbf-ws .ws-b button{min-width:92px;padding:16px 18px;font-size:17px;font-weight:700;display:flex;flex-direction:column;align-items:center;gap:2px;touch-action:manipulation}\n#pbf-app .pbf-ws .ws-b button small{font-size:11px;font-weight:600;opacity:.7}\n.pbf-ws .ws-sp{opacity:.95}\n.pbf-ws .ws-h{font-size:12px;color:#8fa0b3;text-align:center}\n#pbf-app .pbf-ws .ws-b.ws-s button{min-width:62px;padding:10px 12px;font-size:18px}\n#pbf-app .pbf-ws .ws-b.ws-s button.taken{opacity:.35;cursor:not-allowed}\n#pbf-app .pbf-ws .ws-b.ws-s button.on{outline:2px solid #ffd84a}\n#pbf-app .pbf-ws .ws-b.ws-c button{min-width:34px;padding:0}\n#pbf-app .pbf-ws .ws-b.ws-c button small,#pbf-app .b-col button small{font-size:9px;font-weight:700;opacity:1}\n#pbf-app .b-col button.on,#pbf-app .ws-c button.on{outline:2px solid #fff;outline-offset:1px}\n.pbf-sb{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(800px,95%);max-height:88%;overflow:auto;background:rgba(10,14,20,.93);border:1px solid #2d3947;border-radius:12px;padding:12px 14px;color:#fff;pointer-events:none;font-size:13px;z-index:6}\n.pbf-sb .sb-h{font-size:16px;margin-bottom:2px}\n.pbf-sb .sb-s{color:#c9d4e0;margin-bottom:8px;line-height:1.5}\n.pbf-sb .sb-t{margin-top:8px}\n.pbf-sb .sb-th{font-weight:800;font-size:14px;padding:4px 0}\n.pbf-sb table{width:100%;border-collapse:collapse}\n.pbf-sb th{font-size:11px;color:#8fa0b3;font-weight:600;text-align:right;padding:3px 6px;border-bottom:1px solid #2d3947}\n.pbf-sb td{text-align:right;padding:4px 6px;font-variant-numeric:tabular-nums;border-bottom:1px solid rgba(255,255,255,.06)}\n.pbf-sb th:first-child,.pbf-sb td:first-child{text-align:left}\n.pbf-sb tr.me td{background:rgba(255,122,26,.16)}\n.pbf-sb tr.out td{opacity:.5}\n.pbf-sb .sb-sp,.pbf-sb .sb-f{margin-top:8px;color:#8fa0b3;font-size:12px}\n<\/style>\n\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/three.js\/r128\/three.min.js\"><\/script>\n<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/peerjs@1.5.4\/dist\/peerjs.min.js\"><\/script>\n<script>\n(function () {\n  var root = document.getElementById('pbf-app');\n  if (!root || root.dataset.init) return;\n  root.dataset.init = '1';\n  if (!window.THREE) {\n    root.insertAdjacentHTML('afterbegin', '<p style=\"padding:16px\">three.js could not be loaded.<\/p>');\n    return;\n  }\n  const $ = s => root.querySelector(s), $$ = s => Array.from(root.querySelectorAll(s));\n  const cl = (v, a, b) => Math.max(a, Math.min(b, v));\n  \/* ---------- keyboard bindings: the game logic works with fixed internal key names, the player's choices map physical keys onto them ---------- *\/\n  const ACTS = [ \/\/ [id, label, default key, internal name]\n    ['fwd', 'Move forward', 'KeyW', 'KeyW'], ['back', 'Move backward', 'KeyS', 'KeyS'], ['left', 'Move left', 'KeyA', 'KeyA'], ['right', 'Move right', 'KeyD', 'KeyD'],\n    ['run', 'Run', 'ShiftLeft', 'ShiftLeft'], ['crouch', 'Crouch (toggle) \/ slide', 'KeyC', 'KeyC'], ['prone', 'Prone (toggle)', 'KeyX', 'KeyP'], ['jump', 'Jump', 'Space', 'Space'], ['reload', 'Reload', 'KeyR', 'KeyR'],\n    ['peekl', 'Peek left', 'KeyQ', 'KeyQ'], ['peekr', 'Peek right', 'KeyE', 'KeyE'], ['clear', 'Clear hit marks', 'KeyZ', 'KeyZ'], ['fs', 'Fullscreen', 'KeyF', 'KeyF'],\n    ['flag', 'Hand over \/ drop flag', 'KeyG', 'KeyG'], ['score', 'Scoreboard (hold)', 'KeyB', 'KeyB'], ['spec', 'Spectate (between rounds)', 'KeyV', 'KeyV'], ['team', 'Switch team (between rounds)', 'KeyT', 'KeyT']\n  ];\n  const binds = {}; ACTS.forEach(a => { binds[a[0]] = a[2]; });\n  try { const sv = JSON.parse(localStorage.getItem('pbf_keys2') || '{}'); ACTS.forEach(a => { if (typeof sv[a[0]] === 'string' && \/^[A-Za-z0-9]{1,16}$\/.test(sv[a[0]])) binds[a[0]] = sv[a[0]]; }); } catch (e) {}\n  const isShift = c => \/^Shift(Left|Right)$\/.test(c);\n  function canon(code) { \/\/ physical key -> internal name of the action bound to it (null = not bound). Arrow keys always move.\n    if (\/^Arrow\/.test(code)) return code;\n    for (let i = 0; i < ACTS.length; i++) { const a = ACTS[i], b = binds[a[0]]; if (b === code || (a[0] === 'run' && isShift(b) && isShift(code))) return a[3]; }\n    return null;\n  }\n  function kname(c) {\n    if (\/^Key[A-Z]$\/.test(c)) return c[3]; if (\/^Digit\\d$\/.test(c)) return c[5]; if (\/^Numpad\/.test(c)) return 'Num ' + c.slice(6);\n    return ({ Space: 'Space', ShiftLeft: 'Shift', ShiftRight: 'Shift (R)', ControlLeft: 'Ctrl', ControlRight: 'Ctrl (R)', AltLeft: 'Alt', AltRight: 'Alt (R)', CapsLock: 'Caps', Backquote: '`', Minus: '-', Equal: '=', Comma: ',', Period: '.', Slash: '\/', Semicolon: ';', Quote: \"'\", BracketLeft: '[', BracketRight: ']', Backslash: '\\\\', ArrowUp: '\u2191', ArrowDown: '\u2193', ArrowLeft: '\u2190', ArrowRight: '\u2192', Enter: 'Enter', Backspace: 'Backspace' })[c] || c;\n  }\n  const kn = id => '<b>' + kname(binds[id]) + '<\/b>';\n  const esc = s => String(s).replace(\/[&<>\"]\/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n  const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };\n  const KEY = 'pbf_layout_v1';\n\n  \/* ---------- bunker types ---------- *\/\n  function part(g, geo, col, x, y, z, rot) {\n    const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: col, roughness: 0.7 }));\n    m.position.set(x, y, z);\n    if (rot) m.rotation.set(rot.x || 0, rot.y || 0, rot.z || 0);\n    g.add(m); return m;\n  }\n  const TYPES = {\n    snake:   { label: 'Snake',   h: 0.9, col: 0xf2b632, build(g) { part(g, new THREE.CylinderGeometry(.45, .45, 2.4, 20), this.col, 0, .45, 0, { z: Math.PI \/ 2 }); } },\n    can:     { label: 'Can',     h: 1.3, col: 0x3fb6a8, build(g) { part(g, new THREE.CylinderGeometry(.6, .6, 1.3, 24), this.col, 0, .65, 0); } },\n    dorito:  { label: 'Dorito',  h: 1.7, col: 0xe8553d, build(g) { part(g, new THREE.ConeGeometry(1, 1.7, 3), this.col, 0, .85, 0, { y: Math.PI \/ 6 }); } },\n    cake:    { label: 'Cake',    h: 0.9, col: 0xb07cf0, build(g) { part(g, new THREE.CylinderGeometry(1.05, 1.05, .9, 28), this.col, 0, .45, 0); } },\n    temple:  { label: 'Temple',  h: 1.8, col: 0xc9a77a, build(g) {\n      part(g, new THREE.BoxGeometry(1.9, .6, 1.9), this.col, 0, .3, 0);\n      part(g, new THREE.BoxGeometry(1.3, .6, 1.3), this.col, 0, .9, 0);\n      part(g, new THREE.BoxGeometry(.7, .6, .7), this.col, 0, 1.5, 0); } },\n    wing:    { label: 'Wing',    h: 1.3, col: 0x6aa8ff, t0: .25, build(g, tx, tz) { part(g, new THREE.BoxGeometry(2.4, 1.3, tz || .25), this.col, 0, .65, 0); } },\n    tower:   { label: 'Tower',   h: 2.6, col: 0xd9d9d9, build(g) { part(g, new THREE.CylinderGeometry(.45, .55, 2.6, 20), this.col, 0, 1.3, 0); } },\n    dome:    { label: 'Dome',    h: 0.9, col: 0x8bd35a, build(g) { part(g, new THREE.SphereGeometry(.95, 22, 14, 0, Math.PI * 2, 0, Math.PI \/ 2), this.col, 0, 0, 0); } },\n    \/\/ Arrow: two wings at a 90\u00b0 angle, tip pointing to +z\n    arrow:   { label: 'Arrow',   h: 1.3, col: 0xff9f43, t0: .25, build(g, tx, tz) {\n      \/\/ one solid chevron with a mitered point (tip towards +z); two hidden convex halves give the walking collision its two exact outlines\n      const t = tz || .25, h = t \/ 2, r = Math.SQRT1_2, Tz = .65, Ex = 1.272, Ez = -.622;\n      const E1o = [-Ex - r * h, Ez + r * h], E1i = [-Ex + r * h, Ez - r * h], E2o = [Ex + r * h, Ez + r * h], E2i = [Ex - r * h, Ez - r * h], O = [0, Tz + t * r], I = [0, Tz - t * r];\n      const prism = pts => { const sh = new THREE.Shape(); pts.forEach((p, k) => k ? sh.lineTo(p[0], -p[1]) : sh.moveTo(p[0], -p[1])); sh.closePath(); const geo = new THREE.ExtrudeGeometry(sh, { depth: 1.3, bevelEnabled: false }); geo.rotateX(-Math.PI \/ 2); return geo; };\n      part(g, prism([E1o, O, E2o, E2i, I, E1i]), this.col, 0, 0, 0).userData.noHull = true; \/\/ the visible chevron is not convex: its outline would fill the inside of the V, so only the two halves below count for walking collision\n      [[E1o, O, I, E1i], [O, E2o, E2i, I]].forEach(q => { const m = part(g, prism(q), this.col, 0, 0, 0); m.visible = false; m.userData.noEdge = true; });\n    } },\n    \/\/ Cross: plus shape\n    cross:   { label: 'Cross',   h: 1.2, col: 0xf06292, t0: .5, build(g, tx, tz) { tx = tx || .5; tz = tz || .5;\n      part(g, new THREE.BoxGeometry(2.6, 1.2, tz), this.col, 0, .6, 0);\n      part(g, new THREE.BoxGeometry(tx, 1.2, 2.6), this.col, 0, .6, 0); } },\n    \/\/ Roof: dorito with one long side (triangular prism)\n    roof:    { label: 'Roof',    h: 1.15, col: 0x4dd0e1, build(g) { part(g, new THREE.CylinderGeometry(.75, .75, 2.8, 3), this.col, 0, .375, 0, { x: -Math.PI \/ 2, z: Math.PI \/ 2 }); } },\n    \/\/ Window: a roof with a window cut through it; wh = window height in metres (set per bunker)\n    window:  { label: 'Window',  h: 1.15, col: 0x80cbc4, wh0: .5, wy0: .3, ww0: 1, build(g, tx, tz, ty, o) {\n      o = o || {};\n      const H = 1.125, HW = .65, LEN = 2.8, WW = Math.min(Math.max(o.ww || 1, .2), LEN - .4), y0 = Math.min(Math.max(o.wy == null ? .3 : o.wy, 0), H - .3), wy = Math.min(Math.max(ty || .5, .1), H - y0 - .15), y1 = y0 + wy;\n      const hw = y => HW * (1 - y \/ H);\n      const slab = (ya, yb, len, x0) => {\n        const sh = new THREE.Shape();\n        sh.moveTo(-hw(ya), ya); sh.lineTo(hw(ya), ya);\n        if (yb >= H - 1e-6) sh.lineTo(0, H); else { sh.lineTo(hw(yb), yb); sh.lineTo(-hw(yb), yb); }\n        sh.closePath();\n        const geo = new THREE.ExtrudeGeometry(sh, { depth: len, bevelEnabled: false });\n        geo.rotateY(Math.PI \/ 2); \/\/ extrude along x\n        part(g, geo, this.col, x0, 0, 0);\n      };\n      const e = (LEN - WW) \/ 2;\n      slab(0, H, e, -LEN \/ 2); slab(0, H, e, WW \/ 2);\n      if (y0 > .01) slab(0, y0, WW, -WW \/ 2); slab(y1, H, WW, -WW \/ 2); } },\n    \/\/ Staple: U shape\n    staple:  { label: 'Staple',  h: 1.1, col: 0xaed581, t0: .4, build(g, tx, tz) { tx = tx || .4; tz = tz || .4;\n      part(g, new THREE.BoxGeometry(2.6, 1.1, tz), this.col, 0, .55, -.7 + tz \/ 2);\n      part(g, new THREE.BoxGeometry(tx, 1.1, 1.4), this.col, -1.3 + tx \/ 2, .55, 0);\n      part(g, new THREE.BoxGeometry(tx, 1.1, 1.4), this.col, 1.3 - tx \/ 2, .55, 0); } },\n    \/\/ Square: simple cube\/block\n    square:  { label: 'Square', h: 1.2, col: 0x90a4ae, build(g) { part(g, new THREE.BoxGeometry(1.2, 1.2, 1.2), this.col, 0, .6, 0); } },\n    \/\/ Plexi: door-sized plexiglas panel in a frame. See-through, but paintballs burst on it\n    plexi:   { label: 'Plexi',   h: 2.0, col: 0x37474f, build(g) {\n      \/\/ frame (bottom, top, left, right)\n      part(g, new THREE.BoxGeometry(1.1, .08, .08), this.col, 0, .04, 0);\n      part(g, new THREE.BoxGeometry(1.1, .08, .08), this.col, 0, 1.96, 0);\n      part(g, new THREE.BoxGeometry(.1, 2.0, .08), this.col, -.5, 1.0, 0);\n      part(g, new THREE.BoxGeometry(.1, 2.0, .08), this.col, .5, 1.0, 0);\n      \/\/ transparent pane\n      const glass = part(g, new THREE.BoxGeometry(.9, 1.84, .04), 0xbfe6ff, 0, 1.0, 0);\n      glass.material.transparent = true; glass.material.opacity = .28;\n      glass.material.roughness = .1; glass.material.depthWrite = false;\n      glass.userData.glass = true; } }\n  };\n\n  \/* ---------- scene ---------- *\/\n  const canvas = $('canvas'), stage = $('.pbf-stage');\n  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });\n  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));\n  renderer.shadowMap.enabled = true;\n  renderer.autoClear = false; \/\/ we render the world first, then the held marker on top\n  renderer.setClearColor(0x9ec9e8);\n  const scene = new THREE.Scene();\n  scene.background = new THREE.Color(0x9ec9e8);\n  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 400);\n  const hemi = new THREE.HemisphereLight(0xffffff, 0x4a6b3a, 0.85); scene.add(hemi);\n  const sun = new THREE.DirectionalLight(0xffffff, 0.8);\n  sun.position.set(14, 30, 10); sun.castShadow = true; sun.shadow.mapSize.set(1024, 1024);\n  Object.assign(sun.shadow.camera, { left: -45, right: 45, top: 45, bottom: -45, near: 1, far: 90 });\n  scene.add(sun);\n  const outside = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshStandardMaterial({ color: 0x3d7a33, roughness: 1 }));\n  outside.rotation.x = -Math.PI \/ 2; outside.position.y = -0.02; outside.receiveShadow = true; scene.add(outside);\n\n  const FIELD_COLS = [['#5aa84a', 'Green'], ['#3f7fbf', 'Blue'], ['#c9b27a', 'Sand'], ['#8a8f96', 'Grey'], ['#b5573a', 'Clay'], ['#e8e8e8', 'White'], ['#2f3b4a', 'Dark']];\n  let fieldCol = FIELD_COLS[0][0];\n  \/\/ Custom ground texture (picture on the same website); the tile size says how many metres one copy of the picture covers\n  const TEXS = [\n    { id: 'grass', name: 'Grass', url: 'https:\/\/speedsim.nl\/wp-content\/uploads\/2026\/10\/95_artificial-green-grass-texture-seamless.jpg', tex: null, lum: .4, loading: false, failed: false },\n    { id: 'concrete', name: 'Concrete', url: 'https:\/\/speedsim.nl\/wp-content\/uploads\/2026\/10\/111_concrete-bare-dirty-texture-seamless.jpg', tex: null, lum: .5, loading: false, failed: false },\n    { id: 'carpet', name: 'Blue carpet', url: 'https:\/\/speedsim.nl\/wp-content\/uploads\/2026\/10\/Blue-Grass-Texture-Carpet.png', tex: null, lum: .4, loading: false, failed: false }\n  ];\n  let texId = 0; \/\/ which ground picture is used when the texture is on\n  let fieldBr = 1; \/\/ ground brightness (colour and texture), 1 = as is\n  let fieldTex = false, texSize = 8;\n  const curTex = () => TEXS[texId];\n  function loadTex() {\n    const T = curTex();\n    if (T.tex || T.loading || T.failed) return;\n    T.loading = true;\n    const img = new Image(); img.crossOrigin = 'anonymous';\n    img.onload = () => {\n      T.loading = false;\n      const t = new THREE.Texture(img); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.anisotropy = 8; t.needsUpdate = true; T.tex = t;\n      try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(img, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let sum = 0; for (let i = 0; i < d.length; i += 4) sum += (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) \/ 255; T.lum = sum \/ 64; } catch (e) {}\n      if (fieldTex && curTex() === T) buildField();\n    };\n    img.onerror = () => { T.loading = false; T.failed = true; if (curTex() === T) fieldTex = false; try { fcSync(); buildField(); setHud('The ground texture could not be loaded, so the colour is used.'); } catch (e) {} };\n    img.src = T.url;\n  }\n  const okHex = c => typeof c === 'string' && \/^#[0-9a-f]{6}$\/i.test(c);\n  const lumOf = hex => { const n = parseInt(hex.slice(1), 16); return (0.2126 * (n >> 16 & 255) + 0.7152 * (n >> 8 & 255) + 0.0722 * (n & 255)) \/ 255; };\n  let W = 30, L = 40;\n  \/\/ Start zones (colored bands): w = width, d = depth (the wall side is fixed), x = sideways offset\n  const bases = { red: { w: 30, d: 2, x: 0, h: .05 }, blue: { w: 30, d: 2, x: 0, h: .05 } };\n  function normBase(b) {\n    b.w = cl(+b.w || W, 1, W);\n    b.d = cl(+b.d || 2, .5, Math.max(.5, Math.min(10, L \/ 2 - 3)));\n    const mx = (W - b.w) \/ 2; b.x = cl(+b.x || 0, -mx, mx);\n    b.h = cl(+b.h || .05, .05, 1);\n  }\n  const fieldG = new THREE.Group(), bunkerG = new THREE.Group(), hallG = new THREE.Group();\n  scene.add(fieldG, bunkerG, hallG);\n  let lightK = 1; \/\/ lighting in steps of 10 %, saved with the field so every player sees the same\n  const lt = { h: .85, s: .8, tube: null };\n  function applyLight() {\n    hemi.intensity = lt.h * lightK; sun.intensity = lt.s * lightK;\n    gunHemi.intensity = 1.0 * lightK; gunSun.intensity = .7 * lightK; \/\/ the marker in your hands follows the lighting too\n    if (lt.tube) lt.tube.color.setScalar(Math.min(1, .35 + lightK * .65)); \/\/ the TL tubes dim with the lighting\n  }\n  let indoor = false; const HALL_H = 7; \/\/ indoor hall: walls, roof and TL lights around the field\n\n  function labelTex(text) {\n    const c = document.createElement('canvas'); c.width = 512; c.height = 128;\n    const x = c.getContext('2d');\n    x.font = 'bold 54px system-ui,sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';\n    const w = Math.min(500, x.measureText(text).width + 52);\n    x.fillStyle = 'rgba(12,16,22,.88)'; x.fillRect((512 - w) \/ 2, 18, w, 92);\n    x.fillStyle = '#fff'; x.fillText(text, 256, 66, 460);\n    return new THREE.CanvasTexture(c);\n  }\n  function makeLabel(text) {\n    const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: labelTex(text), depthTest: false, transparent: true }));\n    s.scale.set(4, 1, 1); s.renderOrder = 10; return s;\n  }\n  function disposeGroup(g) {\n    g.traverse(o => {\n      if (o.geometry && !o.isSprite) o.geometry.dispose();\n      if (o.material) [].concat(o.material).forEach(m => { if (m.map) m.map.dispose(); m.dispose(); });\n    });\n    while (g.children.length) g.remove(g.children[0]);\n  }\n\n\n  function buildHall() { \/\/ outdoor: sky + sun. indoor: dark hall with walls, roof beams and rows of TL tubes\n    disposeGroup(hallG); hallG.userData.roof = null;\n    const sky = indoor ? 0x15181d : 0x9ec9e8;\n    scene.background.set(sky); renderer.setClearColor(sky);\n    if (!indoor) {\n      hemi.color.set(0xffffff); hemi.groundColor.set(0x4a6b3a); lt.h = .85;\n      sun.color.set(0xffffff); lt.s = .8; sun.position.set(14, 30, 10); lt.tube = null;\n      applyLight(); return;\n    }\n    hemi.color.set(0xdfe8f5); hemi.groundColor.set(0x2b2e33); lt.h = .55;\n    sun.color.set(0xeef4ff); lt.s = .7; sun.position.set(2, 40, 3); \/\/ light from the ceiling\n    outside.material.color.set(0x2c2f34); \/\/ concrete floor around the field\n    const hw = W \/ 2 + 4, hl = L \/ 2 + 5, H = HALL_H;\n    const wallM = new THREE.MeshStandardMaterial({ color: 0x474c54, roughness: .9 });\n    const steelM = new THREE.MeshStandardMaterial({ color: 0x23262b, roughness: .6, metalness: .5 });\n    const roofM = new THREE.MeshStandardMaterial({ color: 0x2b2e34, roughness: 1 });\n    const wall = (w, x, z, ry) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, H), wallM); m.position.set(x, H \/ 2, z); m.rotation.y = ry; hallG.add(m); }; \/\/ one sided: only seen from inside\n    wall(hw * 2, 0, -hl, 0); wall(hw * 2, 0, hl, Math.PI); wall(hl * 2, hw, 0, -Math.PI \/ 2); wall(hl * 2, -hw, 0, Math.PI \/ 2);\n    const pil = (x, z) => { const m = new THREE.Mesh(new THREE.BoxGeometry(.45, H, .45), steelM); m.position.set(x, H \/ 2, z); hallG.add(m); };\n    for (let i = 0; i <= 4; i++) { const x = -hw + .22 + (hw * 2 - .44) * i \/ 4; pil(x, -hl + .22); pil(x, hl - .22); }\n    for (let i = 1; i < 6; i++) { const z = -hl + .22 + (hl * 2 - .44) * i \/ 6; pil(-hw + .22, z); pil(hw - .22, z); }\n    const stripe = new THREE.MeshStandardMaterial({ color: 0xd9a021, roughness: .8 }); \/\/ yellow safety band along the bottom of the walls\n    [[hw * 2, 0, -hl + .02, 0], [hw * 2, 0, hl - .02, 0], [hl * 2, hw - .02, 0, Math.PI \/ 2], [hl * 2, -hw + .02, 0, Math.PI \/ 2]].forEach(([w, x, z, ry]) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, .25, .04), stripe); m.position.set(x, 1.1, z); m.rotation.y = ry; hallG.add(m); });\n    const roof = new THREE.Group(); hallG.add(roof); hallG.userData.roof = roof; \/\/ hidden while the camera is above it (editing \/ top view)\n    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(hw * 2, hl * 2), roofM); ceil.rotation.x = Math.PI \/ 2; ceil.position.y = H; roof.add(ceil);\n    const nb = Math.max(3, Math.round(hl * 2 \/ 8));\n    for (let i = 0; i < nb; i++) { const z = -hl + hl * 2 * (i + .5) \/ nb; const b = new THREE.Mesh(new THREE.BoxGeometry(hw * 2, .35, .3), steelM); b.position.set(0, H - .18, z); roof.add(b); } \/\/ roof beams\n    const tube = lt.tube = new THREE.MeshBasicMaterial({ color: 0xf3f9ff }), housing = new THREE.MeshStandardMaterial({ color: 0x8d949c, roughness: .5, metalness: .6 });\n    const cols = Math.max(2, Math.round(W \/ 7)), rows = Math.max(3, Math.round(L \/ 5));\n    for (let ci = 0; ci < cols; ci++) for (let ri = 0; ri < rows; ri++) {\n      const x = -W \/ 2 + W * (ci + .5) \/ cols, z = -L \/ 2 + L * (ri + .5) \/ rows, y = H - .55;\n      const hs = new THREE.Mesh(new THREE.BoxGeometry(.3, .07, 1.3), housing); hs.position.set(x, y + .1, z); roof.add(hs);\n      [-.07, .07].forEach(dx => { const t = new THREE.Mesh(new THREE.CylinderGeometry(.018, .018, 1.2, 8), tube); t.rotation.x = Math.PI \/ 2; t.position.set(x + dx, y + .04, z); roof.add(t); }); \/\/ two TL tubes per fixture\n      const ch = new THREE.Mesh(new THREE.BoxGeometry(.02, .45, .02), steelM); ch.position.set(x, H - .3, z); roof.add(ch); \/\/ hanger\n    }\n    applyLight();\n  }\n  function buildField() {\n    clearShots();\n    disposeGroup(fieldG);\n    const useTex = fieldTex && !!curTex().tex;\n    const grass = new THREE.Mesh(new THREE.PlaneGeometry(W, L), new THREE.MeshStandardMaterial({ color: new THREE.Color(useTex ? 0xffffff : fieldCol).multiplyScalar(fieldBr), roughness: 1 }));\n    if (outside.material.map) { outside.material.map.dispose(); outside.material.map = null; }\n    outside.material.color.set(fieldCol).multiplyScalar(.68 * fieldBr); \/\/ the ground around the field is a darker shade\n    outside.material.needsUpdate = true;\n    if (fieldTex && !curTex().tex) loadTex();\n    buildHall();\n    if (useTex) {\n      const tg = curTex().tex.clone(); tg.needsUpdate = true; tg.repeat.set(W \/ texSize, L \/ texSize); grass.material.map = tg;\n      if (!indoor) { const to = curTex().tex.clone(); to.needsUpdate = true; to.repeat.set(400 \/ texSize, 400 \/ texSize); outside.material.map = to; outside.material.color.set(0xaaaaaa).multiplyScalar(fieldBr); outside.material.needsUpdate = true; }\n    }\n    const light = (useTex ? curTex().lum : lumOf(fieldCol)) * fieldBr > .6; \/\/ light fields get dark lines so they stay visible\n    grass.rotation.x = -Math.PI \/ 2; grass.receiveShadow = true; fieldG.add(grass);\n    const mid = new THREE.Mesh(new THREE.PlaneGeometry(W, .14), new THREE.MeshBasicMaterial({ color: light ? 0x2b2f36 : 0xffffff }));\n    mid.rotation.x = -Math.PI \/ 2; mid.position.y = .01; fieldG.add(mid);\n    { \/\/ Flag mode: zone lines at 25 \/ 50 \/ 75 % between the two start zones\n      const zr = -(L \/ 2 - bases.red.d), zb = L \/ 2 - bases.blue.d;\n      [.25, .5, .75].forEach(f => {\n        const ln = new THREE.Mesh(new THREE.PlaneGeometry(W, .14), new THREE.MeshBasicMaterial({ color: light ? 0xb36b00 : 0xffe14d }));\n        ln.rotation.x = -Math.PI \/ 2; ln.position.set(0, .015, zr + (zb - zr) * f); ln.userData.zl = true; ln.visible = \/^flag\/.test($('.n-mode').value); fieldG.add(ln);\n      });\n    }\n    [[-1, 0xe5484d, 'RED', bases.red], [1, 0x3b82f6, 'BLUE', bases.blue]].forEach(([s, c, t, bs]) => {\n      const base = new THREE.Mesh(new THREE.BoxGeometry(bs.w, bs.h, bs.d), new THREE.MeshStandardMaterial({ color: c }));\n      base.position.set(bs.x, bs.h \/ 2, s * (L \/ 2 - bs.d \/ 2)); fieldG.add(base);\n      const sp = makeLabel(t); sp.userData.zone = true; sp.position.set(bs.x, 1.2, s * (L \/ 2 + 1.4)); fieldG.add(sp);\n    });\n    const net = new THREE.MeshBasicMaterial({ color: 0x111111, transparent: true, opacity: .2, side: THREE.DoubleSide, depthWrite: false });\n    [[W, 0, L \/ 2, 0], [W, 0, -L \/ 2, 0], [L, W \/ 2, 0, Math.PI \/ 2], [L, -W \/ 2, 0, Math.PI \/ 2]].forEach(([len, x, z, ry]) => {\n      const m = new THREE.Mesh(new THREE.PlaneGeometry(len, 3.5), net);\n      m.position.set(x, 1.75, z); m.rotation.y = ry; fieldG.add(m);\n    });\n    [[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(([a, b]) => {\n      const p = new THREE.Mesh(new THREE.CylinderGeometry(.08, .08, 3.6, 8), new THREE.MeshStandardMaterial({ color: 0x222222 }));\n      p.position.set(a * W \/ 2, 1.8, b * L \/ 2); fieldG.add(p);\n    });\n    updateImgGeom();\n  }\n\n  \/* ---------- field image (overlay on the floor) ---------- *\/\n  const IMGKEY = 'pbf_img_v1';\n  const img = { tex: null, mesh: null, w: 0, h: 0, rot: 0, data: null, fx: false, fy: false };\n  function applyFlip() { if (img.mesh) img.mesh.scale.set(img.fx ? -1 : 1, img.fy ? -1 : 1, 1); }\n  function updateImgGeom() {\n    if (!img.mesh) return;\n    img.mesh.geometry.dispose(); img.mesh.geometry = new THREE.PlaneGeometry(W, L);\n  }\n  function applyImgUI() {\n    const has = !!img.tex;\n    ['.i-show', '.i-op', '.i-rot', '.i-fh', '.i-fv', '.i-fit', '.i-rm'].forEach(s => { $(s).disabled = !has; });\n    $('.i-fit').disabled = !has || locked;\n    if (img.mesh) { img.mesh.visible = has && $('.i-show').checked; img.mesh.material.opacity = $('.i-op').value \/ 100; }\n  }\n  function clearImage(keepStore) {\n    if (img.mesh) { scene.remove(img.mesh); img.mesh.geometry.dispose(); img.mesh.material.dispose(); }\n    if (img.tex) img.tex.dispose();\n    img.mesh = null; img.tex = null; img.data = null;\n    if (!keepStore) { applyImgUI(); try { localStorage.removeItem(IMGKEY); } catch (e) {} }\n  }\n  function persistImg() {\n    try { localStorage.setItem(IMGKEY, JSON.stringify({ d: img.data, r: img.rot, fx: img.fx ? 1 : 0, fy: img.fy ? 1 : 0 })); }\n    catch (e) { setHud('Image is too large to save; it will only stay visible during this visit.'); }\n  }\n  function setImage(src, rot, done, fx, fy) {\n    const im = new Image();\n    im.onload = () => {\n      const s = Math.min(1, 1600 \/ Math.max(im.width, im.height));\n      const c = document.createElement('canvas');\n      c.width = Math.max(1, Math.round(im.width * s)); c.height = Math.max(1, Math.round(im.height * s));\n      c.getContext('2d').drawImage(im, 0, 0, c.width, c.height);\n      clearImage(true);\n      img.w = c.width; img.h = c.height; img.rot = rot || 0; img.fx = !!fx; img.fy = !!fy; img.data = c.toDataURL('image\/jpeg', .85);\n      img.tex = new THREE.CanvasTexture(c);\n      img.tex.center.set(.5, .5); img.tex.rotation = img.rot * Math.PI \/ 2;\n      img.tex.anisotropy = renderer.capabilities.getMaxAnisotropy();\n      img.mesh = new THREE.Mesh(new THREE.PlaneGeometry(W, L), new THREE.MeshBasicMaterial({ map: img.tex, transparent: true, depthWrite: false }));\n      img.mesh.rotation.x = -Math.PI \/ 2; img.mesh.position.y = .06; img.mesh.renderOrder = 1; applyFlip();\n      scene.add(img.mesh);\n      $('.i-show').checked = true; applyImgUI();\n      if (done) done();\n    };\n    im.onerror = () => setHud('That image could not be loaded.');\n    im.src = src;\n  }\n\n  \/* ---------- camera ---------- *\/\n  const cam = { th: .7, ph: 1, r: 50, t: new THREE.Vector3() };\n  function panBy(dx, dy) {\n    const k = 2 * cam.r * Math.tan(camera.fov * Math.PI \/ 360) \/ Math.max(1, stage.clientHeight);\n    const ky = k \/ Math.max(.25, Math.cos(cam.ph));\n    cam.t.x += -Math.cos(cam.th) * dx * k - Math.sin(cam.th) * dy * ky;\n    cam.t.z += Math.sin(cam.th) * dx * k - Math.cos(cam.th) * dy * ky;\n    cam.t.x = cl(cam.t.x, -(W \/ 2 + 15), W \/ 2 + 15);\n    cam.t.z = cl(cam.t.z, -(L \/ 2 + 15), L \/ 2 + 15);\n  }\n  function view(v) {\n    cam.t.set(0, 0, 0);\n    const m = Math.max(W, L);\n    if (v === 'top') { cam.th = 0; cam.ph = .06; cam.r = m * 1.3; }\n    else if (v === 'red') { cam.th = Math.PI; cam.ph = 1.22; cam.r = L * .85; }\n    else if (v === 'blue') { cam.th = 0; cam.ph = 1.22; cam.r = L * .85; }\n    else { cam.th = .7; cam.ph = 1; cam.r = m * 1.25; }\n  }\n  function updateCam() {\n    cam.ph = cl(cam.ph, .05, 1.5); cam.r = cl(cam.r, 8, 130);\n    camera.position.set(\n      cam.t.x + cam.r * Math.sin(cam.ph) * Math.sin(cam.th),\n      cam.t.y + cam.r * Math.cos(cam.ph),\n      cam.t.z + cam.r * Math.sin(cam.ph) * Math.cos(cam.th));\n    camera.lookAt(cam.t);\n  }\n  \/\/ Walking field of view: the slider is the horizontal angle (like in most games); the camera needs the vertical one\n  let fovH = 80;\n  try { const q = +localStorage.getItem('pbf_fov'); if (q >= 50 && q <= 100) fovH = q; } catch (e) {}\n  const fovV = () => Math.min(100, 2 * Math.atan(Math.tan(fovH * Math.PI \/ 360) \/ (camera.aspect || 1.78)) * 180 \/ Math.PI);\n  function resize() {\n    const w = stage.clientWidth, h = stage.clientHeight;\n    if (!w || !h) return;\n    renderer.setSize(w, h, false); camera.aspect = w \/ h; camera.updateProjectionMatrix();\n  }\n  if (window.ResizeObserver) new ResizeObserver(resize).observe(stage); else window.addEventListener('resize', resize);\n\n  \/* ---------- bunkers ---------- *\/\n  const bunkers = [];\n  let uid = 1, sel = null, tool = null, mode = 'play', locked = false; \/\/ modes: 'play' | 'build' | 'quiz' | 'strat'\n  \/\/ side panel: every mode has its own title, its own tabs and its own panels (see sideRender)\n  const SIDE = {\n    play:  { ico: '\u25b6', t: 'Play', s: 'Walk the field, shoot and play online', tabs: [['maps', '\ud83d\uddfa Maps'], ['wpn', '\ud83d\udd2b Weapon'], ['net', '\ud83c\udf10 Online']] },\n    build: { ico: '\ud83d\udee0', t: 'Build', s: 'Design your own field', tabs: [['bunk', '\ud83e\uddf1 Bunkers'], ['field', '\ud83d\udcd0 Field'], ['zones', '\ud83d\udea9 Zones'], ['image', '\ud83d\uddbc Image'], ['maps', '\ud83d\uddfa Maps & save'], ['stl', '\ud83d\udda8 3D print']] },\n    quiz:  { ico: '\ud83c\udfaf', t: 'Practice', s: 'Learn the bunker names and callouts', tabs: [['quiz', '\ud83c\udfaf Quiz']] },\n    strat: { ico: '\ud83d\udccb', t: 'Strategy', s: 'Plan tactics and show them to your team', tabs: [['strat', '\ud83d\udccb Board'], ['maps', '\ud83d\uddfa Maps & save']] }\n  };\n  const PANEL = { maps: '.p-maps', wpn: '.p-wpn', net: '.p-net', bunk: '[data-sec=\"bunk\"]', field: '[data-sec=\"field\"]', zones: '[data-sec=\"zones\"]', image: '[data-sec=\"image\"]', stl: '.p-stl', quiz: '.p-quiz', strat: '.p-stb' };\n  const sdTab = { play: 'maps', build: 'bunk', quiz: 'quiz', strat: 'strat' };\n  const fp = { prone: false, proneCd: 0, on: false, yaw: 0, pitch: 0, x: 0, z: 0, keys: {}, last: 0, drag: null, firing: false, cool: 0, recoil: 0, lean: 0, bob: 0, moving: false, noLock: false, hand: 1, handS: 1, y: 0, gy: 0, vy: 0, jumpReq: false, ammo: { m4: 170, pistol: 170 }, wt: 0, reloadT: 0, reloadD: 1, slide: null, slideCd: 0, crouchPress: false, eye: 1.7 };\n  const ring = new THREE.Mesh(new THREE.RingGeometry(1.35, 1.55, 40), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: .95 }));\n  ring.rotation.x = -Math.PI \/ 2; ring.position.y = .05; ring.visible = false; scene.add(ring);\n\n  \/\/ The default name is neutral (\"Bunker 1\"); you fill in the real callout yourself.\n  function autoName() {\n    let n = 1;\n    while (bunkers.some(b => b.name === 'Bunker ' + n)) n++;\n    return 'Bunker ' + n;\n  }\n  const isNamed = b => !\/^Bunker \\d+$\/.test(b.name);\n  \/\/ Dimensions: length = local x-axis, width = local z-axis, height = y-axis (in meters)\n  function baseSize(body) {\n    body.updateMatrixWorld(true);\n    const box = new THREE.Box3(), v = new THREE.Vector3();\n    body.traverse(o => {\n      if (!o.isMesh) return;\n      const p = o.geometry.attributes.position;\n      for (let i = 0; i < p.count; i++) { v.fromBufferAttribute(p, i).applyMatrix4(o.matrixWorld); box.expandByPoint(v); }\n    });\n    const s = box.getSize(new THREE.Vector3());\n    return { x: Math.max(.01, s.x), y: Math.max(.01, s.y), z: Math.max(.01, s.z) };\n  }\n  const dimOf = d => ({ l: d.base.x * d.s.x, b: d.base.z * d.s.z, h: d.base.y * d.s.y });\n  \/\/ Thin-wall shapes: d.th = wall thickness in metres (null = it scales with the size, as before)\n  const wo = d => d.type === 'window' ? { wy: d.wy == null ? null : d.wy \/ d.s.y, ww: d.ww && d.ww \/ d.s.x } : null;\n  const tkey = d => d.th + '|' + d.wh + '|' + d.wy + '|' + d.ww + '|' + d.s.x + '|' + d.s.y + '|' + d.s.z;\n  function rebuildBody(d) {\n    const T = TYPES[d.type];\n    while (d.body.children.length) { const o = d.body.children[0]; d.body.remove(o); if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }\n    d.body.scale.set(1, 1, 1);\n    T.build(d.body, d.th \/ d.s.x, d.th \/ d.s.z, d.wh \/ d.s.y, wo(d));\n    d.body.traverse(o => { if (o.isMesh) { o.castShadow = !o.userData.glass; o.receiveShadow = true; if (!o.userData.glass) o.userData.def = o.material.color.getHex(); } });\n    d.base = baseSize(d.body); d.hulls = footprints(d.body); d.ck = null; d.tk = tkey(d);\n    applyColor(d);\n  }\n  \/\/ Metal edge strips: real bunkers have small metal strips along their edges. They are built from the real (scaled) shape, so they keep a fixed width whatever the size\n  let edgesOn = true;\n  try { edgesOn = localStorage.getItem('pbf_edge2') !== '0'; } catch (e) {}\n  const _sa = new THREE.Vector3(), _sb = new THREE.Vector3(), _sd = new THREE.Vector3(), _su = new THREE.Vector3(), _sv = new THREE.Vector3(), _sn = new THREE.Vector3();\n  function buildStrips(d) {\n    if (d.strips) { d.group.remove(d.strips); d.strips.geometry.dispose(); d.strips.material.dispose(); d.strips = null; }\n    if (!edgesOn || d.type === 'plexi' || !d.group) return;\n    d.body.updateMatrix();\n    const pos = [], idx = [], HW = .009; \/\/ strip width about 1.8 cm\n    d.body.children.forEach(o => {\n      if (!o.isMesh || o.userData.glass || o.userData.noEdge) return;\n      o.updateMatrix();\n      const m = new THREE.Matrix4().multiplyMatrices(d.body.matrix, o.matrix), eg = new THREE.EdgesGeometry(o.geometry, 28), p = eg.attributes.position;\n      for (let i = 0; i + 1 < p.count; i += 2) {\n        _sa.fromBufferAttribute(p, i).applyMatrix4(m); _sb.fromBufferAttribute(p, i + 1).applyMatrix4(m);\n        _sd.subVectors(_sb, _sa); const len = _sd.length(); if (len < .03) continue; _sd.divideScalar(len);\n        _sn.set(Math.abs(_sd.y) < .9 ? 0 : 1, Math.abs(_sd.y) < .9 ? 1 : 0, 0);\n        _su.crossVectors(_sn, _sd).normalize().multiplyScalar(HW); _sv.crossVectors(_sd, _su).normalize().multiplyScalar(HW);\n        const b0 = pos.length \/ 3;\n        for (let e = 0; e < 2; e++) for (let a = 0; a < 2; a++) for (let c = 0; c < 2; c++) {\n          const base = e ? _sb : _sa, k = e ? HW : -HW; \/\/ the ends stick out a little so the corners are covered\n          pos.push(base.x + _sd.x * k + _su.x * (a ? 1 : -1) + _sv.x * (c ? 1 : -1), base.y + _sd.y * k + _su.y * (a ? 1 : -1) + _sv.y * (c ? 1 : -1), base.z + _sd.z * k + _su.z * (a ? 1 : -1) + _sv.z * (c ? 1 : -1));\n        }\n        [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]].forEach(q => idx.push(b0 + q[0], b0 + q[1], b0 + q[2], b0 + q[0], b0 + q[2], b0 + q[3]));\n      }\n      eg.dispose();\n    });\n    if (!pos.length) return;\n    const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx); geo.computeVertexNormals();\n    d.strips = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 0, roughness: .45, side: THREE.DoubleSide }));\n    d.group.add(d.strips);\n  }\n  function applyScale(d) {\n    if ((d.th && TYPES[d.type].t0 || d.type === 'window' && (d.wh || d.wy != null || d.ww)) && d.tk !== tkey(d)) rebuildBody(d);\n    d.body.scale.set(d.s.x, d.s.y, d.s.z);\n    d.label.position.y = d.base.y * d.s.y + .8;\n    buildStrips(d);\n  }\n  \/* ---------- exact floor shapes for walking collision ---------- *\/\n  \/\/ Every part of a bunker (box, cylinder, wing...) becomes a convex outline seen from above, taken from its real mesh.\n  \/\/ Complex bunkers (Staple, Cross, Arrow...) are several outlines together, so you can stand inside a Staple.\n  function hull2(pts) { \/\/ convex hull, counter-clockwise\n    const seen = {}, a = [];\n    pts.forEach(q => { const k = Math.round(q[0] * 1e4) + ',' + Math.round(q[1] * 1e4); if (!seen[k]) { seen[k] = 1; a.push(q); } });\n    a.sort((p, q) => p[0] - q[0] || p[1] - q[1]);\n    if (a.length < 3) return a;\n    const cr = (o, p, q) => (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0]), lo = [], up = [];\n    a.forEach(q => { while (lo.length > 1 && cr(lo[lo.length - 2], lo[lo.length - 1], q) <= 1e-9) lo.pop(); lo.push(q); });\n    for (let i = a.length - 1; i >= 0; i--) { const q = a[i]; while (up.length > 1 && cr(up[up.length - 2], up[up.length - 1], q) <= 1e-9) up.pop(); up.push(q); }\n    lo.pop(); up.pop(); return lo.concat(up);\n  }\n  function footprints(body) {\n    body.updateMatrixWorld(true);\n    const out = [], v = new THREE.Vector3();\n    body.traverse(o => {\n      if (!o.isMesh || o.userData.noHull) return;\n      const pos = o.geometry.attributes.position, pts = [];\n      for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i).applyMatrix4(o.matrixWorld); pts.push([v.x, v.z]); }\n      const h = hull2(pts); if (h.length >= 3) out.push(h);\n    });\n    return out;\n  }\n  function polysOf(d) { \/\/ outlines scaled to the bunker's current size (cached)\n    const k = d.s.x + '|' + d.s.z;\n    if (d.ck !== k) {\n      d.ck = k; d.cr = 0;\n      d.cp = d.hulls.map(h => h.map(q => [q[0] * d.s.x, q[1] * d.s.z]));\n      d.cp.forEach(h => h.forEach(q => { d.cr = Math.max(d.cr, Math.hypot(q[0], q[1])); }));\n    }\n    return d.cp;\n  }\n  function circlePoly(poly, px, pz, r) { \/\/ circle (player) vs convex outline: returns the push-out [dx, dz] or null\n    let inside = true, bd = 1e9, qx0 = 0, qz0 = 0, nx = 0, nz = 0;\n    for (let i = 0, n = poly.length; i < n; i++) {\n      const a = poly[i], b = poly[(i + 1) % n], ex = b[0] - a[0], ez = b[1] - a[1], l2 = ex * ex + ez * ez || 1e-9;\n      let t = ((px - a[0]) * ex + (pz - a[1]) * ez) \/ l2; t = t < 0 ? 0 : t > 1 ? 1 : t;\n      const qx = a[0] + ex * t, qz = a[1] + ez * t, d = Math.hypot(px - qx, pz - qz);\n      if (d < bd) { bd = d; qx0 = qx; qz0 = qz; const l = Math.sqrt(l2); nx = ez \/ l; nz = -ex \/ l; }\n      if ((px - a[0]) * ez - (pz - a[1]) * ex > 0) inside = false;\n    }\n    if (inside) return [nx * (bd + r), nz * (bd + r)];\n    if (bd >= r) return null;\n    const k = (r - bd) \/ (bd || 1e-9);\n    return [(px - qx0) * k, (pz - qz0) * k];\n  }\n\n  function addBunker(type, x, z, rot, name, s, col, th, wh, wy, ww) {\n    const T = TYPES[type];\n    const d = { id: uid++, type, x, z, rot: rot || 0, name: name || autoName(type), reveal: false, s: { x: 1, y: 1, z: 1 } };\n    if (Array.isArray(s) && s.length === 3) d.s = { x: cl(+s[0] || 1, .05, 30), y: cl(+s[1] || 1, .05, 30), z: cl(+s[2] || 1, .05, 30) };\n    if (type === 'arrow') d.s.z = d.s.x; \/\/ keep the Arrow's 90\u00b0 angle\n    d.th = T.t0 && +th > 0 ? cl(+th, .05, 3) : null;\n    const g = new THREE.Group(), body = new THREE.Group(); d.wh = type === 'window' && +wh > 0 ? cl(+wh, .1, 3) : null;\n    d.wy = type === 'window' && wy != null && +wy >= 0 ? cl(+wy, 0, 3) : null; d.ww = type === 'window' && +ww > 0 ? cl(+ww, .2, 6) : null;\n    T.build(body, d.th && d.th \/ d.s.x, d.th && d.th \/ d.s.z, d.wh && d.wh \/ d.s.y, wo(d));\n    if (d.th || d.wh || d.wy != null || d.ww) d.tk = tkey(d);\n    body.traverse(o => { if (o.isMesh) { o.castShadow = !o.userData.glass; o.receiveShadow = true; if (!o.userData.glass) o.userData.def = o.material.color.getHex(); } });\n    d.col = okHex(col) ? col.toLowerCase() : (col === BTEX.id ? col : null); \/\/ null = the shape's own colour; 'tex:grain' = the bunker picture\n    d.base = baseSize(body); d.hulls = footprints(body); d.body = body; g.add(body);\n    g.position.set(x, 0, z); g.rotation.y = d.rot; g.userData.d = d;\n    d.group = g; applyColor(d);\n    d.label = makeLabel(d.name); g.add(d.label);\n    applyScale(d);\n    bunkerG.add(g); bunkers.push(d);\n    updateLabels();\n    return d;\n  }\n  function relabel(d) {\n    d.label.material.map.dispose();\n    d.label.material.map = labelTex(d.name); d.label.material.needsUpdate = true;\n  }\n  function removeBunker(d) {\n    bunkerG.remove(d.group); disposeGroup(d.group);\n    bunkers.splice(bunkers.indexOf(d), 1);\n    if (sel === d) select(null);\n  }\n  function clearBunkers() { clearShots(); while (bunkers.length) removeBunker(bunkers[0]); select(null); }\n  \/\/ Keep a position inside the field and out of the colored start zones (pushed out to the nearest free edge)\n  function fit(x, z) {\n    x = cl(x, -(W \/ 2 - 1), W \/ 2 - 1); z = cl(z, -(L \/ 2 - 1), L \/ 2 - 1);\n    const bs = z < 0 ? bases.red : bases.blue, s = z < 0 ? -1 : 1, m = .5;\n    const lo = bs.x - bs.w \/ 2 - m, hi = bs.x + bs.w \/ 2 + m, zf = L \/ 2 - bs.d - m;\n    if (x > lo && x < hi && Math.abs(z) > zf) {\n      const o = [{ d: Math.abs(z) - zf, x, z: s * zf }];\n      if (lo >= -(W \/ 2 - 1)) o.push({ d: x - lo, x: lo, z });\n      if (hi <= W \/ 2 - 1) o.push({ d: hi - x, x: hi, z });\n      o.sort((a, b) => a.d - b.d);\n      return { x: o[0].x, z: o[0].z };\n    }\n    return { x, z };\n  }\n  function inStrip(x, z) {\n    const bs = z < 0 ? bases.red : bases.blue;\n    return Math.abs(z) > L \/ 2 - bs.d && Math.abs(x - bs.x) < bs.w \/ 2;\n  }\n  function moveTo(d, x, z) {\n    const pt = fit(x, z); d.x = pt.x; d.z = pt.z;\n    d.group.position.set(d.x, 0, d.z);\n    if (sel === d) ring.position.set(d.x, .05, d.z);\n  }\n  function glow(d, col, k) {\n    d.group.traverse(o => { if (o.isMesh) { o.material.emissive.setHex(col || 0); o.material.emissiveIntensity = col ? (k || .6) : 0; } });\n  }\n  function updateLabels() {\n    const on = $('.f-lbl').checked;\n    bunkers.forEach(b => { b.label.visible = quiz.on ? !!b.reveal : on; }); \/\/ bunker names always follow \"Show names\"\n    fieldG.children.forEach(o => { if (o.userData.zone) o.visible = !(match.on && MATCH_HIDE_TEAM_SIGNS); if (o.userData.zl) o.visible = \/^flag\/.test($('.n-mode').value); }); \/\/ RED \/ BLUE signs behind the start zones\n    net.players.forEach(p => { if (p.grp) p.grp.userData.lb.visible = !match.on || p.team === net.team; }); \/\/ enemy name tags are hidden in a match\n  }\n\n  \/* ---------- selection + panel ---------- *\/\n  function select(d) {\n    sel = d;\n    ring.visible = !!d && mode === 'build' && !fp.on;\n    updateRing();\n    $('.f-sel').hidden = !d;\n    if (d) { if (mode === 'build') sideTab('bunk'); $('.f-name').value = d.name; $('.f-selt').textContent = TYPES[d.type].label; fillDims(d); }\n    refreshList();\n  }\n  function updateRing() {\n    if (!sel) return;\n    ring.position.set(sel.x, .05, sel.z);\n    const m = dimOf(sel); ring.scale.setScalar(Math.max(.7, Math.max(m.l, m.b) \/ 2.4));\n  }\n  \/\/ Bunker picture instead of a colour (same website). Shared by all bunkers; loaded once, then every bunker that uses it is refreshed\n  const BTEX = { id: 'tex:grain', url: 'https:\/\/speedsim.nl\/wp-content\/uploads\/2026\/10\/black-grained-rough-pattern-scaled-1.jpg', tex: null, loading: false, failed: false };\n  function loadBTex() {\n    if (BTEX.tex || BTEX.loading || BTEX.failed) return;\n    BTEX.loading = true;\n    const img = new Image(); img.crossOrigin = 'anonymous';\n    img.onload = () => {\n      BTEX.loading = false;\n      const t = new THREE.Texture(img); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(2, 2); t.anisotropy = 8; t.needsUpdate = true; BTEX.tex = t;\n      bunkers.forEach(b => { if (b.col === BTEX.id) applyColor(b); });\n    };\n    img.onerror = () => { BTEX.loading = false; BTEX.failed = true; bunkers.forEach(b => { if (b.col === BTEX.id) applyColor(b); }); try { setHud('The bunker texture could not be loaded, so the default colour is used.'); } catch (e) {} };\n    img.src = BTEX.url;\n  }\n  function applyColor(d) { \/\/ custom colour or picture for every solid part of a bunker (the see-through glass of a Plexi keeps its look)\n    const useTex = d.col === BTEX.id;\n    if (useTex && !BTEX.tex) loadBTex();\n    d.body.traverse(o => {\n      if (!o.isMesh || o.userData.glass) return;\n      const m = o.material, want = useTex && BTEX.tex ? BTEX.tex : null;\n      if (m.map !== want) { m.map = want; m.needsUpdate = true; }\n      m.color.setHex(want ? 0xffffff : (d.col && !useTex ? parseInt(d.col.slice(1), 16) : o.userData.def));\n    });\n  }\n  const BUNKER_COLS = ['#e8553d', '#f2b632', '#8bd35a', '#3fb6a8', '#3b82f6', '#b07cf0', '#f06292', '#90a4ae', '#37474f', '#ffffff'];\n  function fillCol(d) {\n    $('.s-col').value = (d.col && d.col !== BTEX.id && d.col) || '#' + ('000000' + TYPES[d.type].col.toString(16)).slice(-6);\n    $$('.s-sw button').forEach(b => b.classList.toggle('sel', b.dataset.c === d.col));\n    $('.s-coltype').textContent = 'of this shape (' + TYPES[d.type].label + ')';\n  }\n  function setBunkerCol(d, c) { d.col = c; applyColor(d); fillCol(d); persist(); }\n  BUNKER_COLS.forEach(c => { const b = document.createElement('button'); b.dataset.c = c; b.style.background = c; b.title = c; b.onclick = () => { if (sel) setBunkerCol(sel, c); }; $('.s-sw').appendChild(b); });\n  { const b = document.createElement('button'); b.className = 'tx'; b.dataset.c = BTEX.id; b.title = 'Rough grained texture'; b.style.backgroundImage = 'url(\"' + BTEX.url + '\")'; b.onclick = () => { if (sel) setBunkerCol(sel, BTEX.id); }; $('.s-sw').appendChild(b); }\n  $('.s-col').oninput = e => { if (sel && okHex(e.target.value)) setBunkerCol(sel, e.target.value.toLowerCase()); };\n  $('.s-coldef').onclick = () => { if (sel) setBunkerCol(sel, null); };\n  $('.s-colall').onclick = () => { if (!sel) return; const c = sel.col, t = sel.type; bunkers.forEach(b => { if (b.type === t) { b.col = c; applyColor(b); } }); fillCol(sel); persist(); };\n  function fillDims(d) {\n    fillCol(d);\n    const m = dimOf(d);\n    $('.s-l').value = m.l.toFixed(2); $('.s-b').value = m.b.toFixed(2); $('.s-h').value = m.h.toFixed(2);\n    const T = TYPES[d.type];\n    $('.s-throw').style.display = T.t0 ? '' : 'none';\n    $('.s-whrow').style.display = d.type === 'window' ? '' : 'none';\n    if (d.type === 'window') { $('.s-wh').value = (d.wh || T.wh0 * d.s.y).toFixed(2); $('.s-wy').value = (d.wy != null ? d.wy : T.wy0 * d.s.y).toFixed(2); $('.s-ww').value = (d.ww || T.ww0 * d.s.x).toFixed(2); }\n    if (T.t0) $('.s-th').value = (d.th || T.t0 * (d.type === 'cross' || d.type === 'staple' ? (d.s.z + d.s.x) \/ 2 : d.s.z)).toFixed(2);\n  }\n  function refreshList() {\n    const box = $('.f-list'); box.innerHTML = '';\n    bunkers.forEach(b => {\n      const el = document.createElement('button');\n      el.textContent = b.name; if (b === sel) el.className = 'on';\n      el.onclick = () => { if (mode === 'build') select(b); };\n      box.appendChild(el);\n    });\n    if (!bunkers.length) box.innerHTML = '<span class=\"note\">No bunkers yet. Generate a field or place one yourself.<\/span>';\n  }\n  function renderTools() {\n    const box = $('.f-types'); box.innerHTML = '';\n    Object.keys(TYPES).forEach(k => {\n      const b = document.createElement('button');\n      b.textContent = TYPES[k].label; b.disabled = locked; if (tool === k) b.className = 'on';\n      b.onclick = () => { tool = tool === k ? null : k; renderTools(); hint(); };\n      box.appendChild(b);\n    });\n  }\n  let hudT = 0; \/\/ messages over the field fade away after a few seconds (the quiz question stays)\n  function setHud(html, keep) { const h = $('.pbf-hud'); h.innerHTML = html; h.classList.remove('off'); clearTimeout(hudT); if (!keep) hudT = setTimeout(() => h.classList.add('off'), 6000); }\n  function hint() {\n    if (fp.on) { if (!quiz.on) setHud(touchMode ? '<b>Left stick<\/b> move \u00b7 <b>right stick<\/b> look \u00b7 <b>FIRE<\/b> shoot' : '<b>Click<\/b> to capture the mouse and shoot \u00b7 <b>Esc<\/b> releases it \u00b7 all keys: \u2699 Settings'); return; }\n    if (quiz.on) return;\n    if (mode === 'strat') { stHint(); return; }\n    if (mode === 'play') { setHud('Drag to rotate the view \u00b7 press <b>Walk<\/b> to enter the field'); return; }\n    setHud(mode === 'quiz'\n      ? 'Pick a practice mode and press <b>Start<\/b>.'\n      : tool ? 'Tap the field to place a <b>' + TYPES[tool].label + '<\/b>.'\n             : locked ? 'Layout locked: tap a bunker to select or rename it. Unlock to place or move bunkers.'\n             : 'Tap a bunker to select it, drag to move it. Drag the field to rotate the view. Pan: right mouse button, Shift or two fingers.');\n  }\n\n  \/* ---------- save \/ load ---------- *\/\n  function serialize() {\n    return JSON.stringify({ v: 1, W, L, fc: fieldCol, fb: fieldBr !== 1 ? Math.round(fieldBr * 100) : undefined, ft: fieldTex ? 1 : undefined, tx: fieldTex && texId ? TEXS[texId].id : undefined, fts: fieldTex ? texSize : undefined, ind: indoor ? 1 : 0, lg: Math.round(lightK * 100), lk: locked ? 1 : 0, zn: { red: [+bases.red.w.toFixed(2), +bases.red.d.toFixed(2), +bases.red.x.toFixed(2), +bases.red.h.toFixed(2)], blue: [+bases.blue.w.toFixed(2), +bases.blue.d.toFixed(2), +bases.blue.x.toFixed(2), +bases.blue.h.toFixed(2)] }, st: stSer(), b: bunkers.map(b => ({ type: b.type, name: b.name, x: +b.x.toFixed(2), z: +b.z.toFixed(2), rot: +b.rot.toFixed(3), s: [+b.s.x.toFixed(4), +b.s.y.toFixed(4), +b.s.z.toFixed(4)], c: b.col || undefined, th: b.th ? +b.th.toFixed(3) : undefined, wh: b.wh ? +b.wh.toFixed(3) : undefined, wy: b.wy != null ? +b.wy.toFixed(3) : undefined, ww: b.ww ? +b.ww.toFixed(3) : undefined })) });\n  }\n  function persist() {\n    if (net.guest) return; \/\/ a guest never overwrites their own saved field with the host's\n    try { localStorage.setItem(KEY, serialize()); } catch (e) {}\n    if (net.host && ncon()) { clearTimeout(net.fieldTimer); net.fieldTimer = setTimeout(sendField, 400); }\n  }\n  function load(s) {\n    try {\n      const o = JSON.parse(s);\n      if (!o || !Array.isArray(o.b)) return false;\n      W = cl(+o.W || 30, 10, 60); L = cl(+o.L || 40, 15, 80);\n      fieldCol = okHex(o.fc) ? o.fc.toLowerCase() : FIELD_COLS[0][0]; fieldBr = cl((+o.fb || 100) \/ 100, .3, 2); fieldTex = !!o.ft; texId = Math.max(0, TEXS.findIndex(t => t.id === o.tx)); texSize = cl(+o.fts || 8, 1, 30); fcSync(); \/\/ older fields have no colour: green\n      if (o.ind !== undefined) indoor = !!o.ind; $('.f-indoor').checked = indoor;\n      if (o.lg !== undefined) lightK = cl(Math.round((+o.lg || 100) \/ 10) \/ 10, .1, 1.5); ltSync(); \/\/ older fields \/ premade maps keep the current setting\n      ['red', 'blue'].forEach(k => {\n        const z = o.zn && o.zn[k];\n        Object.assign(bases[k], z ? { w: +z[0], d: +z[1], x: +z[2], h: z[3] } : { w: W, d: 2, x: 0, h: .05 }); normBase(bases[k]);\n      });\n      clearBunkers(); buildField();\n      stLoad(o.st);\n      o.b.forEach(b => { if (TYPES[b.type]) addBunker(b.type, +b.x || 0, +b.z || 0, +b.rot || 0, String(b.name || '').slice(0, 30), Array.isArray(b.s) ? b.s : null, b.c, b.th, b.wh, b.wy, b.ww); });\n      syncInputs(); refreshList(); view('iso'); $('.z-link').checked = JSON.stringify(bases.red) === JSON.stringify(bases.blue); setLocked(!!o.lk, true); return true;\n    } catch (e) { return false; }\n  }\n  function syncInputs() { $('.f-w').value = W; $('.f-l').value = L; fillZones(); }\n\n  \/* ---------- generate \/ place ---------- *\/\n  function generate() {\n    clearBunkers();\n    const per = cl(+$('.f-cnt').value || 5, 1, 12), keys = Object.keys(TYPES);\n    addBunker('cake', 0, 0, 0);\n    let placed = 0, tries = 0;\n    while (placed < per && tries < 500) {\n      tries++;\n      const type = keys[Math.floor(Math.random() * keys.length)];\n      const p = fit((Math.random() * 2 - 1) * (W \/ 2 - 2), -(1.8 + Math.random() * (L \/ 2 - 5))), q = fit(p.x, -p.z);\n      if (bunkers.every(b => Math.hypot(b.x - p.x, b.z - p.z) > 3.4 && Math.hypot(b.x - q.x, b.z - q.z) > 3.4)) {\n        const rot = Math.random() * Math.PI;\n        addBunker(type, p.x, p.z, rot); addBunker(type, q.x, q.z, Math.PI - rot); placed++;\n      }\n    }\n    refreshList(); persist();\n  }\n  function place(type, x, z) {\n    if (locked || match.on) return;\n    if (inStrip(x, z)) { setHud('That spot is inside a colored start zone. Place the bunker next to it.'); return; }\n    const pt = fit(x, z);\n    const d = addBunker(type, pt.x, pt.z, 0);\n    if ($('.f-mir').checked && Math.abs(pt.z) > 1.2) { const q = fit(pt.x, -pt.z); addBunker(type, q.x, q.z, Math.PI); }\n    select(d); persist();\n  }\n  function applySize() {\n    const oW = W;\n    W = cl(+$('.f-w').value || 30, 10, 60); L = cl(+$('.f-l').value || 40, 15, 80);\n    Object.values(bases).forEach(b => { if (b.w >= oW - .01) b.w = W; normBase(b); });\n    buildField(); bunkers.forEach(b => moveTo(b, b.x, b.z)); syncInputs(); view('iso'); persist();\n  }\n\n  \/* ---------- quiz ---------- *\/\n  const quiz = { on: false, type: 'find', target: null, last: null, score: 0, total: 0, streak: 0, locked: false, timer: 0, grp: false, seq: 0, ans: new Map(), sc: new Map(), joined: new Set(), tq: 0, td: 0, board: [] };\n  function scoreUI() { $('.q-score').textContent = 'Score ' + quiz.score + '\/' + quiz.total + (quiz.streak > 1 ? '  \u00b7  streak ' + quiz.streak : ''); boardUI(); }\n  function boardUI() { \/\/ group practice: live scoreboard of everybody in the room\n    const b = $('.q-board');\n    if (!quiz.grp) { b.innerHTML = ''; return; }\n    b.innerHTML = quiz.board.slice().sort((a, c) => c[1] - a[1] || a[2] - c[2]).map(e => {\n      const p = net.players.get(e[0]); return '<div class=\"' + (e[0] === net.pid ? 'me' : '') + '\"><span>' + esc(p ? p.name : 'Player') + (e[3] > 1 ? ' \ud83d\udd25' + e[3] : '') + '<\/span><b>' + e[1] + '\/' + e[2] + '<\/b><\/div>';\n    }).join('');\n  }\n  function startQuiz() {\n    if (bunkers.filter(isNamed).length < 2) { setHud('First give at least 2 bunkers a custom name under <b>Build<\/b>.'); return; }\n    quiz.on = true; quiz.type = $('.q-type').value; quiz.score = quiz.total = quiz.streak = 0; quiz.last = null;\n    quiz.grp = !!(net.host && ncon() && $('.q-grp').checked); quiz.board = []; quiz.sc.clear(); quiz.ans.clear(); quiz.advancing = 0;\n    if (quiz.grp) { quiz.joined = new Set(net.conns.keys()); net.send({ t: 'qz', on: 1, ty: quiz.type }); }\n    $('.q-start').textContent = 'Stop'; scoreUI(); updateLabels(); nextQ();\n  }\n  function stopQuiz(remote) {\n    if (!quiz.on) return;\n    clearTimeout(quiz.timer); clearTimeout(quiz.tq); clearTimeout(quiz.td);\n    if (!remote && quiz.grp) { if (net.host) net.send({ t: 'qz', on: 0 }); else if (net.guest && ncon()) net.send({ t: 'qj', v: 0 }); }\n    quiz.on = false; quiz.grp = false; quiz.target = null; quiz.board = [];\n    bunkers.forEach(b => { b.reveal = false; glow(b, 0); });\n    $('.pbf-opts').innerHTML = ''; $('.q-start').textContent = 'Start'; boardUI();\n    updateLabels(); hint();\n  }\n  function nextQ() { \/\/ solo, or the host of a group practice\n    const pool = bunkers.filter(b => isNamed(b) && b !== quiz.last);\n    const t = pool[Math.floor(Math.random() * pool.length)];\n    let names = null;\n    if (quiz.type === 'name') {\n      const others = shuffle(Array.from(new Set(bunkers.filter(isNamed).map(b => b.name))).filter(n => n !== t.name)).slice(0, 3);\n      names = shuffle([t.name].concat(others));\n    }\n    quiz.seq++;\n    if (quiz.grp) { quiz.ans.clear(); quiz.advancing = 0; net.send({ t: 'qq', q: quiz.seq, i: bunkers.indexOf(t), o: names }); clearTimeout(quiz.tq); quiz.tq = setTimeout(() => qDone(), 25000); }\n    showQ(t, names);\n  }\n  function showQ(t, names) {\n    bunkers.forEach(b => { b.reveal = false; glow(b, 0); });\n    quiz.locked = false; quiz.target = t; quiz.last = t;\n    const o = $('.pbf-opts'); o.innerHTML = '';\n    if (quiz.type === 'find') {\n      setHud('Where is <b>' + esc(t.name) + '<\/b>?', true);\n    } else {\n      setHud('What is the <b>orange<\/b> bunker called?');\n      (names || [t.name]).forEach(n => {\n        const btn = document.createElement('button'); btn.textContent = n;\n        btn.onclick = () => answer(n === t.name, null, btn);\n        o.appendChild(btn);\n      });\n    }\n    updateLabels();\n  }\n  function answer(ok, clicked, btn) {\n    if (!quiz.on || quiz.locked) return;\n    quiz.locked = true; quiz.total++;\n    if (ok) { quiz.score++; quiz.streak++; } else quiz.streak = 0;\n    const t = quiz.target;\n    t.reveal = true; glow(t, 0x2ecc71, .7);\n    if (!ok && clicked) { clicked.reveal = true; glow(clicked, 0xe5484d, .7); }\n    $$('.pbf-opts button').forEach(x => { x.disabled = true; if (x.textContent === t.name) x.classList.add('ok'); else if (x === btn) x.classList.add('bad'); });\n    setHud((ok ? '\u2705 Correct: <b>' + esc(t.name) + '<\/b>'\n              : '\u274c It was <b>' + esc(t.name) + '<\/b>' + (clicked ? ' (you chose ' + esc(clicked.name) + ')' : '')) + (quiz.grp ? ' \u00b7 waiting for the others\u2026' : ''));\n    updateLabels(); scoreUI();\n    if (quiz.grp) { if (net.host) qRecord(net.pid, quiz.seq, ok); else net.send({ t: 'qa', q: quiz.seq, k: ok ? 1 : 0 }); }\n    else quiz.timer = setTimeout(() => { if (quiz.on) nextQ(); }, ok ? 900 : 1900);\n  }\n  \/* group practice over the network: the host picks the question, everybody answers, the next one comes when all have answered *\/\n  function qRecord(pid, q, ok) { \/\/ host\n    if (!quiz.on || !quiz.grp || q !== quiz.seq || quiz.ans.has(pid)) return;\n    quiz.ans.set(pid, ok);\n    const e = quiz.sc.get(pid) || { s: 0, t: 0, st: 0 }; e.t++; if (ok) { e.s++; e.st++; } else e.st = 0; quiz.sc.set(pid, e);\n    qCheck();\n  }\n  function qCheck() {\n    if (!quiz.on || !quiz.grp || !net.host) return;\n    const act = [net.pid]; quiz.joined.forEach(p => { const c = net.conns.get(p); if (c && c.open) act.push(p); });\n    if (act.every(p => quiz.ans.has(p))) qDone();\n  }\n  function qDone() { \/\/ host: everybody answered (or time is up): share the scores, then the next question\n    if (!quiz.on || !quiz.grp || quiz.advancing) return;\n    quiz.advancing = 1; clearTimeout(quiz.tq);\n    quiz.board = Array.from(quiz.sc, ([p, e]) => [p, e.s, e.t, e.st]);\n    net.send({ t: 'qs', l: quiz.board }); qReveal(); boardUI();\n    const allOk = Array.from(quiz.ans.values()).every(Boolean);\n    quiz.td = setTimeout(() => { if (quiz.on && quiz.grp) nextQ(); }, allOk ? 1100 : 2000);\n  }\n  function qReveal() { \/\/ question over: whoever has not answered yet just sees the solution\n    if (quiz.locked || !quiz.target) return;\n    quiz.locked = true; quiz.target.reveal = true; glow(quiz.target, 0x2ecc71, .7);\n    $$('.pbf-opts button').forEach(x => { x.disabled = true; if (x.textContent === quiz.target.name) x.classList.add('ok'); });\n    setHud('\u23f1 It was <b>' + esc(quiz.target.name) + '<\/b>'); updateLabels();\n  }\n  function qRemote(m) { \/\/ guest side\n    if (!net.guest) return;\n    if (m.t === 'qz') {\n      if (!m.on) { if (quiz.on && quiz.grp) stopQuiz(true); return; }\n      if (match.on) return;\n      if (quiz.on) stopQuiz(true);\n      if (mode !== 'quiz') setMode('quiz');\n      quiz.on = true; quiz.grp = true; quiz.type = m.ty === 'name' ? 'name' : 'find'; quiz.score = quiz.total = quiz.streak = 0; quiz.last = null; quiz.board = []; quiz.seq = 0;\n      $('.q-type').value = quiz.type; $('.q-start').textContent = 'Leave'; scoreUI(); updateLabels();\n      setHud('\ud83c\udfaf Group practice started by the host\u2026'); net.send({ t: 'qj', v: 1 });\n    } else if (m.t === 'qq') {\n      if (!quiz.on || !quiz.grp) return;\n      const t = bunkers[m.i | 0]; if (!t) return;\n      quiz.seq = m.q | 0;\n      showQ(t, Array.isArray(m.o) ? m.o.slice(0, 6).map(x => String(x).slice(0, 30)) : null);\n    } else if (m.t === 'qs') {\n      if (!quiz.on || !quiz.grp || !Array.isArray(m.l)) return;\n      quiz.board = m.l.slice(0, 40).filter(e => Array.isArray(e)).map(e => [e[0] | 0, e[1] | 0, e[2] | 0, e[3] | 0]);\n      qReveal(); boardUI();\n    }\n  }\n\n  \/* ---------- mouse \/ touch ---------- *\/\n  const ray = new THREE.Raycaster(), mouse = new THREE.Vector2(), plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);\n  function pick(e) {\n    const r = canvas.getBoundingClientRect();\n    mouse.set(((e.clientX - r.left) \/ r.width) * 2 - 1, -((e.clientY - r.top) \/ r.height) * 2 + 1);\n    ray.setFromCamera(mouse, camera);\n  }\n  function groundPoint() { const p = new THREE.Vector3(); return ray.ray.intersectPlane(plane, p) ? p : null; }\n  function hitBunker() {\n    const hits = ray.intersectObjects(bunkerG.children, true);\n    for (const h of hits) {\n      if (h.object.isSprite) continue;\n      let o = h.object; while (o && !o.userData.d) o = o.parent;\n      if (o) return o.userData.d;\n    }\n    return null;\n  }\n  const ptrs = new Map(); let st = null;\n  const pdist = () => { const a = Array.from(ptrs.values()); return a.length < 2 ? 0 : Math.hypot(a[0].x - a[1].x, a[0].y - a[1].y); };\n  const pmid = () => { const a = Array.from(ptrs.values()); return a.length < 2 ? { x: 0, y: 0 } : { x: (a[0].x + a[1].x) \/ 2, y: (a[0].y + a[1].y) \/ 2 }; };\n\n  canvas.addEventListener('pointerdown', e => {\n    if (fp.on && net.coach) exitWalk(true); \/\/ a spectator never walks: leave first-person so drawing works\n    if (fp.on) return;\n    if (coachDown(e)) return; \/\/ coach: drawing instead of orbiting\n    canvas.setPointerCapture(e.pointerId); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });\n    if (ptrs.size === 2) { const m0 = pmid(); st = { mode: 'pinch', moved: true, dist: pdist(), mx: m0.x, my: m0.y }; return; }\n    if (ptrs.size > 2) return;\n    pick(e);\n    const force = e.button === 1 || e.button === 2 || e.shiftKey || e.ctrlKey || e.metaKey;\n    if (mode === 'strat') { const s2 = stDown(e, force); if (s2) { st = s2; return; } }\n    const b = mode === 'build' && !force ? hitBunker() : null;\n    if (b && !locked && !match.on) {\n      select(b); const gp = groundPoint();\n      st = { mode: 'drag', d: b, moved: false, sx: e.clientX, sy: e.clientY, ox: gp ? b.x - gp.x : 0, oz: gp ? b.z - gp.z : 0 };\n    } else st = { mode: force ? 'pan' : 'orbit', force, moved: false, sx: e.clientX, sy: e.clientY };\n  });\n  canvas.addEventListener('pointermove', e => {\n    if (!ptrs.has(e.pointerId) || !st) return;\n    const p = ptrs.get(e.pointerId), dx = e.clientX - p.x, dy = e.clientY - p.y;\n    p.x = e.clientX; p.y = e.clientY;\n    if (st.mode === 'pinch') {\n      const nd = pdist(), m1 = pmid();\n      if (nd > 0 && st.dist > 0) cam.r *= st.dist \/ nd;\n      panBy(m1.x - st.mx, m1.y - st.my);\n      st.dist = nd; st.mx = m1.x; st.my = m1.y; return;\n    }\n    if (!st.moved && Math.hypot(e.clientX - st.sx, e.clientY - st.sy) < 5) return;\n    st.moved = true;\n    if (st.mode === 'sdrag' || st.mode === 'sline') { pick(e); const gp = stGP(); if (gp) stMove(st, gp); }\n    else if (st.mode === 'drag') { pick(e); const gp = groundPoint(); if (gp) moveTo(st.d, gp.x + st.ox, gp.z + st.oz); }\n    else if (st.mode === 'pan') panBy(dx, dy);\n    else { cam.th -= dx * .008; cam.ph -= dy * .006; }\n  });\n  function endPtr(e) {\n    if (!ptrs.has(e.pointerId)) return;\n    ptrs.delete(e.pointerId);\n    if (!st) return;\n    if (st.mode === 'pinch') { if (ptrs.size < 2) st = null; return; }\n    const was = st; st = null;\n    if (was.mode === 'sdrag' || was.mode === 'sline') { stEnd(was); return; }\n    if (!was.moved) { if (!was.force) { pick(e); handleClick(); } }\n    else if (was.mode === 'drag') persist();\n  }\n  canvas.addEventListener('pointerup', endPtr);\n  canvas.addEventListener('pointercancel', e => { ptrs.delete(e.pointerId); st = null; });\n  canvas.addEventListener('wheel', e => { e.preventDefault(); if (!fp.on) cam.r *= 1 + e.deltaY * .001; else if (ws.on && performance.now() - fp.wt > 250) { fp.wt = performance.now(); cycleWeapon(e.deltaY > 0 ? 1 : -1); } }, { passive: false });\n\n  function handleClick() {\n    const b = hitBunker();\n    if (mode === 'quiz') { if (quiz.on && quiz.type === 'find' && b) answer(b.name === quiz.target.name, b); return; }\n    if (mode === 'strat') { stClick(); return; }\n    if (mode !== 'build') return; \/\/ Play mode: tapping the field does nothing (no selecting or placing by accident)\n    if (b) { select(b); return; }\n    if (tool && !locked && !match.on) { const gp = groundPoint(); if (gp) place(tool, gp.x, gp.z); }\n    else select(null);\n  }\n\n  \/* ---------- UI bindings ---------- *\/\n  function sideRender() { \/\/ title, tabs and panels of the side panel for the current mode\n    const S = SIDE[mode], cur = sdTab[mode];\n    $('.sd-ico').textContent = S.ico; $('.sd-t').textContent = S.t; $('.sd-s').textContent = S.s;\n    const tb = $('.sd-tabs'); tb.innerHTML = '';\n    S.tabs.forEach(t => { const b = document.createElement('button'); b.className = 'sd-tab' + (t[0] === cur ? ' on' : ''); b.textContent = t[1]; b.onclick = () => sideTab(t[0]); tb.appendChild(b); });\n    tb.hidden = S.tabs.length < 2;\n    Object.keys(PANEL).forEach(k => { const e = $(PANEL[k]); if (e) e.hidden = !(k === cur && S.tabs.some(t => t[0] === k)); });\n    root.classList.toggle('t-nolock', !['bunk', 'field', 'zones'].includes(cur));\n    if (cur === 'stl') stlInfo();\n  }\n  function sideTab(t) {\n    if (!SIDE[mode].tabs.some(x => x[0] === t)) return;\n    sdTab[mode] = t; sideRender();\n    if (t === 'net' && mode === 'play' && !ncon()) listServers();\n  }\n  function setMode(m) {\n    stopQuiz(); const prev = mode; mode = m; tool = null;\n    $$('[data-mode]').forEach(b => { const on = b.dataset.mode === m; b.classList.toggle('on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); });\n    Object.keys(SIDE).forEach(k => root.classList.toggle('m-' + k, k === m));\n    $('.p-build').hidden = m !== 'build'; $('.f-lock').hidden = m !== 'build';\n    $('.p-strat').hidden = m !== 'strat'; stG.visible = m === 'strat'; $('.pbf-stbar').hidden = m !== 'strat'; if (m === 'strat' && prev !== 'strat') { view('top'); stRender(); stUI(); }\n    sideRender();\n    select(null); renderTools(); hint();\n  }\n  $$('[data-mode]').forEach(b => b.onclick = () => setMode(b.dataset.mode));\n  $$('[data-view]').forEach(b => b.onclick = () => { exitWalk(); view(b.dataset.view); });\n  $('.f-lbl').onchange = updateLabels;\n  \/\/ bunker name size: full size in the overview, adjustable in first person (saved on this device)\n  let lblK = .5; try { const v = parseFloat(localStorage.getItem('pbf_lblk')); if (v >= .15 && v <= 1.5) lblK = v; } catch (e) {}\n  $('.f-ls').value = Math.round(lblK * 100); $('.f-lsv').textContent = Math.round(lblK * 100) + '%';\n  let lblSig = '';\n  function lblSync() {\n    const k = fp.on ? lblK : 1, sig = k + '|' + bunkers.length; if (sig === lblSig) return; lblSig = sig;\n    bunkers.forEach(b => { b.label.scale.set(4 * k, k, 1); });\n  }\n  $('.f-ls').oninput = e => { lblK = cl(+e.target.value \/ 100, .15, 1.5); $('.f-lsv').textContent = Math.round(lblK * 100) + '%'; lblSig = ''; lblSync(); try { localStorage.setItem('pbf_lblk', String(lblK)); } catch (er) {} };\n  $('.f-apply').onclick = () => { if (!locked) applySize(); };\n  $('.f-gen').onclick = () => { if (!locked) generate(); };\n  function blankField() { \/\/ remove every bunker so you can build from a blank canvas (also works on a locked premade map)\n    if (net.guest) { setHud('In an online game the host controls the layout.'); return; }\n    if (match.on) { setHud('You cannot change the field during a match.'); return; }\n    if (!bunkers.length) { setHud('The field is already blank. Pick a shape and tap the field to place a bunker.'); return; }\n    if (!confirm((locked ? 'The layout is locked. ' : '') + 'Remove all ' + bunkers.length + ' bunkers and start from a blank field?')) return;\n    if (locked) setLocked(false);\n    clearBunkers(); refreshList(); persist();\n    setHud('Blank field. Pick a shape and tap the field to place your first bunker.');\n  }\n  $('.f-clear').onclick = blankField; $('.f-blank').onclick = blankField;\n  $('.f-name').oninput = e => { if (sel) { sel.name = e.target.value.slice(0, 30) || autoName(); relabel(sel); refreshList(); persist(); } };\n  $('.f-rl').onclick = () => { if (sel && !locked) { sel.rot -= Math.PI \/ 12; sel.group.rotation.y = sel.rot; persist(); } };\n  $('.f-rr').onclick = () => { if (sel && !locked) { sel.rot += Math.PI \/ 12; sel.group.rotation.y = sel.rot; persist(); } };\n  $('.f-dup').onclick = () => {\n    if (!sel || locked) return;\n    const m = dimOf(sel), off = Math.max(m.l, m.b) + .3, dx = sel.x + off > W \/ 2 - 1 ? -off : off;\n    const d = addBunker(sel.type, sel.x, sel.z, sel.rot, isNamed(sel) ? sel.name : undefined, [sel.s.x, sel.s.y, sel.s.z], sel.col, sel.th, sel.wh, sel.wy, sel.ww);\n    moveTo(d, sel.x + dx, sel.z);\n    select(d); persist();\n  };\n  $('.f-del').onclick = () => { if (sel && !locked) { removeBunker(sel); refreshList(); persist(); } };\n  $('.f-exp').onclick = () => {\n    const s = serialize();\n    if (navigator.clipboard) navigator.clipboard.writeText(s).then(() => setHud('Field copied to your clipboard.'), () => prompt('Copy this field:', s));\n    else prompt('Copy this field:', s);\n  };\n  $('.f-imp').onclick = () => {\n    if (!mapsOk()) return;\n    const s = prompt('Paste the field code here:');\n    if (!s) return;\n    let o = null; try { o = JSON.parse(s); } catch (e) {}\n    if (o && Array.isArray(o.b)) mapLoad(o, 'the pasted field'); else setHud('That field code is invalid.');\n  };\n  $('.q-start').onclick = () => quiz.on ? stopQuiz() : startQuiz();\n\n  [['.s-l', 'x'], ['.s-b', 'z'], ['.s-h', 'y']].forEach(([cls, axis]) => {\n    $(cls).oninput = e => {\n      if (!sel || locked) return;\n      const v = parseFloat(e.target.value);\n      if (!(v >= .05 && v <= 30)) return;\n      const k = v \/ sel.base[axis];\n      if (sel.type === 'arrow' && axis !== 'y') {\n        \/\/ Arrow: length and width are linked so the 90\u00b0 angle stays intact\n        sel.s.x = sel.s.z = k;\n        const m = dimOf(sel);\n        if (axis === 'x') $('.s-b').value = m.b.toFixed(2); else $('.s-l').value = m.l.toFixed(2);\n      } else sel.s[axis] = k;\n      applyScale(sel); updateRing(); persist();\n    };\n  });\n  $('.s-th').oninput = e => {\n    if (!sel || locked || !TYPES[sel.type].t0) return;\n    const v = parseFloat(e.target.value);\n    if (!(v >= .05 && v <= 3)) return;\n    sel.th = v; applyScale(sel); updateRing(); persist();\n  };\n  [['.s-wh', 'wh', .1, 3], ['.s-ww', 'ww', .2, 6], ['.s-wy', 'wy', 0, 3]].forEach(([cls, key, lo, hi]) => {\n    $(cls).oninput = e => {\n      if (!sel || locked || sel.type !== 'window') return;\n      const v = parseFloat(e.target.value);\n      if (!(v >= lo && v <= hi)) return;\n      sel[key] = v; applyScale(sel); updateRing(); persist();\n    };\n  });\n  $('.s-whauto').onclick = () => {\n    if (!sel || locked || sel.type !== 'window') return;\n    sel.wh = sel.ww = sel.wy = null; rebuildBody(sel); sel.tk = null; applyScale(sel); fillDims(sel); updateRing(); persist();\n  };\n  $('.s-thauto').onclick = () => {\n    if (!sel || locked || !sel.th) return;\n    sel.th = null; rebuildBody(sel); sel.tk = null; applyScale(sel); fillDims(sel); updateRing(); persist();\n  };\n  $('.s-reset').onclick = () => {\n    if (!sel || locked) return;\n    sel.s = { x: 1, y: 1, z: 1 }; sel.th = null; sel.wh = sel.wy = sel.ww = null; if (TYPES[sel.type].t0 || sel.type === 'window') { rebuildBody(sel); sel.tk = null; }\n    applyScale(sel); fillDims(sel); updateRing(); persist();\n  };\n\n  canvas.addEventListener('contextmenu', e => e.preventDefault());\n\n  $('.i-pick').onclick = () => $('.i-file').click();\n  $('.i-file').onchange = e => {\n    const f = e.target.files && e.target.files[0]; if (!f) return;\n    const r = new FileReader();\n    r.onload = () => setImage(r.result, 0, () => { persistImg(); view('top'); setHud('Image loaded. Enter the real field dimensions and place the bunkers.'); });\n    r.readAsDataURL(f); e.target.value = '';\n  };\n  $('.i-show').onchange = applyImgUI;\n  $('.i-op').oninput = applyImgUI;\n  $('.i-rm').onclick = () => clearImage();\n  $('.i-rot').onclick = () => { if (!img.tex) return; img.rot = (img.rot + 1) % 4; img.tex.rotation = img.rot * Math.PI \/ 2; persistImg(); };\n  $('.i-fh').onclick = () => { if (!img.mesh) return; img.fx = !img.fx; applyFlip(); persistImg(); };\n  $('.i-fv').onclick = () => { if (!img.mesh) return; img.fy = !img.fy; applyFlip(); persistImg(); };\n  $('.i-fit').onclick = () => {\n    if (!img.tex || locked) return;\n    const odd = img.rot % 2, iw = odd ? img.h : img.w, ih = odd ? img.w : img.h;\n    let nw = W, nl = W * ih \/ iw;\n    if (nl > 80) { nl = 80; nw = 80 * iw \/ ih; }\n    if (nl < 15) { nl = 15; nw = 15 * iw \/ ih; }\n    $('.f-w').value = Math.round(cl(nw, 10, 60) * 10) \/ 10; $('.f-l').value = Math.round(nl * 10) \/ 10;\n    applySize(); view('top');\n  };\n\n  \/* ---------- start zones ---------- *\/\n  const ZIN = { red: { w: '.z-rw', d: '.z-rd', x: '.z-rx', h: '.z-rh' }, blue: { w: '.z-bw', d: '.z-bd', x: '.z-bx', h: '.z-bh' } };\n  function fillZones(skip) {\n    Object.keys(ZIN).forEach(tm => Object.keys(ZIN[tm]).forEach(k => {\n      const el = $(ZIN[tm][k]); if (el !== skip) el.value = +bases[tm][k].toFixed(2);\n    }));\n  }\n  Object.keys(ZIN).forEach(tm => Object.keys(ZIN[tm]).forEach(k => {\n    const el = $(ZIN[tm][k]);\n    el.oninput = e => {\n      if (locked) return;\n      const v = parseFloat(e.target.value); if (!isFinite(v)) return;\n      (($('.z-link').checked) ? ['red', 'blue'] : [tm]).forEach(n => { bases[n][k] = v; normBase(bases[n]); });\n      buildField(); bunkers.forEach(b => moveTo(b, b.x, b.z)); fillZones(e.target); persist();\n    };\n    el.onchange = () => fillZones();\n  }));\n\n  \/* ---------- layout lock ---------- *\/\n  function fcSync() {\n    $('.ff-pick').value = fieldCol;\n    $$('.ff-sw button').forEach(b => b.classList.toggle('sel', !b.dataset.tx && b.dataset.c === fieldCol && !fieldTex));\n    $$('.ff-sw button.tx').forEach(b => b.classList.toggle('sel', fieldTex && b.dataset.tx === TEXS[texId].id)); $('.ft-row').hidden = !fieldTex;\n    $('.ft-size').value = texSize; $('.ft-v').textContent = texSize + ' m';\n    $('.fb-size').value = Math.round(fieldBr * 100); $('.fb-v').textContent = Math.round(fieldBr * 100) + '%';\n  }\n  function setFieldCol(c) {\n    if (locked || match.on || !okHex(c)) { fcSync(); return; }\n    fieldCol = c.toLowerCase(); fieldTex = false; fcSync(); buildField(); persist();\n  }\n  function setFieldTex(i) {\n    if (locked || match.on) { fcSync(); return; }\n    texId = i; fieldTex = true; fcSync(); buildField(); persist();\n  }\n  $('.fb-size').oninput = e => {\n    if (locked || match.on) { fcSync(); return; }\n    fieldBr = cl((+e.target.value || 100) \/ 100, .3, 2); fcSync(); buildField(); persist();\n  };\n  $('.ft-size').oninput = e => {\n    if (locked || match.on) { fcSync(); return; }\n    texSize = cl(+e.target.value || 8, 1, 30); fcSync(); buildField(); persist();\n  };\n  FIELD_COLS.forEach(([c, n]) => {\n    const b = document.createElement('button'); b.dataset.c = c; b.style.background = c; b.title = n;\n    b.onclick = () => setFieldCol(c); $('.ff-sw').appendChild(b);\n  });\n  TEXS.forEach((T, i) => {\n    const b = document.createElement('button'); b.className = 'tx'; b.dataset.tx = T.id; b.title = T.name + ' (texture)'; b.style.backgroundImage = 'url(\"' + T.url + '\")';\n    b.onclick = () => setFieldTex(i); $('.ff-sw').appendChild(b);\n  });\n  $('.ff-pick').onchange = e => setFieldCol(e.target.value);\n  function ltSync() { $('.f-lt').value = Math.round(lightK * 100); $('.f-ltv').textContent = Math.round(lightK * 100) + '%'; }\n  $('.f-lt').oninput = e => {\n    if (net.guest) { ltSync(); setHud('In an online game the host controls the layout.'); return; }\n    if (locked || match.on) { ltSync(); return; }\n    lightK = Math.round(+e.target.value \/ 10) \/ 10; ltSync(); applyLight(); persist();\n  };\n  $('.f-indoor').onchange = e => {\n    if (net.guest) { e.target.checked = indoor; setHud('In an online game the host controls the layout.'); return; }\n    if (locked || match.on) { e.target.checked = indoor; return; }\n    indoor = e.target.checked; buildField(); persist();\n  };\n  $('.fc-reset').onclick = () => setFieldCol(FIELD_COLS[0][0]);\n  const LOCK_OFF = ['.ft-size', '.fb-size', '.f-indoor', '.f-lt', '.ff-pick', '.fc-reset', '.ff-sw button', '.s-col', '.s-sw button', '.z-rw', '.z-rd', '.z-rx', '.z-rh', '.z-bw', '.z-bd', '.z-bx', '.z-bh', '.f-dup', '.z-link', '.f-w', '.f-l', '.f-cnt', '.f-apply', '.f-gen', '.f-clear', '.f-mir', '.s-l', '.s-b', '.s-h', '.s-th', '.s-thauto', '.s-wh', '.s-ww', '.s-wy', '.s-whauto', '.s-reset', '.f-rl', '.f-rr', '.f-del'];\n  function setLocked(v, noSave) {\n    locked = !!v;\n    if (locked) tool = null;\n    LOCK_OFF.forEach(sel => $$(sel).forEach(el => { el.disabled = locked; }));\n    const lb = $('.f-lock');\n    lb.classList.toggle('on', locked);\n    lb.textContent = locked ? '\ud83d\udd12 Layout locked' : '\ud83d\udd13 Lock layout';\n    lb.title = locked ? 'Click to unlock the layout' : \"Lock the layout so bunkers can't be placed or moved by accident\";\n    $('.f-locknote').hidden = !locked;\n    applyImgUI(); renderTools(); hint();\n    if (!noSave) persist();\n  }\n  $('.f-lock').onclick = () => { if (net.guest) { setHud('In an online game the host controls the layout.'); return; } setLocked(!locked); };\n\n  \/* ---------- marker (held in first person) + paintballs ---------- *\/\n  \/\/ The marker is drawn in its own scene, on top of the world, so it never clips into bunkers.\n  const gunScene = new THREE.Scene();\n  const gunHemi = new THREE.HemisphereLight(0xffffff, 0x667788, 1.0); gunScene.add(gunHemi);\n  const gunSun = new THREE.DirectionalLight(0xffffff, 0.7); gunSun.position.set(.4, 1, .6); gunScene.add(gunSun);\n  const gunRig = new THREE.Object3D(); gunScene.add(gunRig);\n  const gun = new THREE.Group(), muzzle = new THREE.Object3D();\n  const GUN_BASE = new THREE.Vector3(.17, -.21, -.5);\n  gunRig.add(gun);\n  const WEAPONS = {\n    m4:     { cool: .085, auto: true,  speed: 85, spread: .006, rec: .8,  mag: 170, reload: 2.6, walk: 3, run: 5.5, cw: 1.8, cr: 2.4, crouch: 1.3, crouchH: 1.4, crouchS: .65, base: [.17, -.17, -.5],  muzzle: [0, .008, -.32] },\n    pistol: { cool: .06,  auto: false, speed: 65, spread: .012, rec: 1.3, mag: 170, reload: 1.8, walk: 3.3, run: 6.4, cw: 2, cr: 2.7, crouch: 1.25, crouchH: 1.35, crouchS: .5, base: [.115, -.145, -.3], muzzle: [0, .031, -.172] }\n  };\n  const WLIST = ['m4', 'pistol'], WNAME = { m4: 'Rifle', pistol: 'Pistol' };\n  let weapon = 'm4';\n  try { const sv = localStorage.getItem('pbf_weapon'); if (WEAPONS[sv] && WLIST.includes(sv)) weapon = sv; } catch (e) {}\n  try { if (localStorage.getItem('pbf_hand') === '-1') fp.hand = fp.handS = -1; } catch (e) {}\n  \/\/ weapon colours (Player settings): the parts of the rifle and pistol you can recolour. Other players see them too\n  const WCK = ['rb', 'rg', 'rm', 'rt', 'ra', 'ps', 'pf']; \/\/ rifle body, grip & tank, magazine, trigger, accents, pistol slide, pistol frame\n  const WC_DEF = { rb: '#23262b', rg: '#1f2329', rm: '#0f1114', rt: '#e0242b', ra: '#c8873a', ps: '#7a22b8', pf: '#15171a' };\n  const parseWc = a => { const o = Object.assign({}, WC_DEF); if (Array.isArray(a)) WCK.forEach((k, i) => { if (okHex(a[i])) o[k] = a[i].toLowerCase(); }); return o; };\n  const wcArr = o => WCK.map(k => o[k]);\n  const wcol = parseWc(null);\n  try { const sv = JSON.parse(localStorage.getItem('pbf_wcol') || 'null'); if (sv && typeof sv === 'object') WCK.forEach(k => { if (okHex(sv[k])) wcol[k] = sv[k].toLowerCase(); }); } catch (e) {}\n  const wcOf = p => (p && p.wc) || WC_DEF;\n  const shade = (hex, k) => { const c = new THREE.Color(hex); c.r = Math.min(1, c.r * k); c.g = Math.min(1, c.g * k); c.b = Math.min(1, c.b * k); return c; };\n  function makeHiCapa(root, wc) { \/\/ hi-capa pistol parts added to 'root'; returns the slide group so it can move (used in first person and on other players)\n    wc = wc || WC_DEF;\n    const purple = new THREE.MeshStandardMaterial({ color: wc.ps, roughness: .32, metalness: .75 });\n    const purpleD = new THREE.MeshStandardMaterial({ color: shade(wc.ps, .64), roughness: .4, metalness: .7 });\n    const frame = new THREE.MeshStandardMaterial({ color: wc.pf, roughness: .6, metalness: .25 });\n    const grip = new THREE.MeshStandardMaterial({ color: shade(wc.pf, 1.7), roughness: .9, metalness: .05 });\n    const ti = new THREE.MeshStandardMaterial({ color: 0x62d6b0, roughness: .25, metalness: .9 });\n    const gold = new THREE.MeshStandardMaterial({ color: 0xd9b24a, roughness: .25, metalness: .9 });\n    const steel = new THREE.MeshStandardMaterial({ color: 0x8b9199, roughness: .3, metalness: .9 });\n    const green = new THREE.MeshBasicMaterial({ color: 0x8dff3c }), red = new THREE.MeshBasicMaterial({ color: 0xff3b1a });\n    const bx = (par, w, h, d, m, x, y, z, rx) => { const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); k.position.set(x, y, z); if (rx) k.rotation.x = rx; par.add(k); return k; };\n    const cy = (par, r, len, m, x, y, z) => { const k = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 14), m); k.position.set(x, y, z); k.rotation.x = Math.PI \/ 2; par.add(k); return k; };\n    const GR = -.17; \/\/ the grip leans back\n    \/\/ ---- frame (stays put) ----\n    bx(root, .027, .02, .1, frame, 0, .003, -.065);               \/\/ dust cover \/ rail section under the slide\n    bx(root, .027, .03, .095, frame, 0, -.002, .028);             \/\/ rear of the frame\n    bx(root, .026, .008, .03, frame, 0, .014, .088, .18);         \/\/ beavertail\n    bx(root, .03, .108, .05, frame, 0, -.062, .043, GR);          \/\/ grip core\n    bx(root, .0015, .095, .043, grip, .0155, -.06, .043, GR); bx(root, .0015, .095, .043, grip, -.0155, -.06, .043, GR); \/\/ textured side panels\n    for (let i = 0; i < 6; i++) { bx(root, .0012, .0035, .042, frame, .0166, -.098 + i * .0135, .044 - i * .0022 + .0, GR); bx(root, .0012, .0035, .042, frame, -.0166, -.098 + i * .0135, .044 - i * .0022, GR); } \/\/ grip texture rows\n    bx(root, .028, .09, .004, grip, 0, -.056, .0125, GR);         \/\/ front strap\n    bx(root, .03, .02, .004, grip, 0, -.115, .0085, GR);\n    bx(root, .034, .012, .06, frame, 0, -.119, .0585, GR);        \/\/ flared magazine base\n    bx(root, .026, .006, .052, grip, 0, -.127, .0595, GR);        \/\/ magazine floor plate\n    \/\/ trigger guard (squared) + trigger\n    bx(root, .007, .004, .046, frame, 0, -.029, -.034);\n    bx(root, .007, .022, .004, frame, 0, -.019, -.055);\n    bx(root, .0055, .012, .0045, ti, 0, -.0155, -.0155, .35);     \/\/ trigger shoe\n    bx(root, .004, .006, .006, ti, 0, -.007, -.008);\n    \/\/ controls\n    bx(root, .003, .006, .028, ti, -.0155, .006, .03);            \/\/ thumb safety (left side)\n    bx(root, .0035, .007, .008, ti, -.0155, .01, .05);\n    bx(root, .003, .005, .02, gold, -.0145, .0015, -.005);        \/\/ slide stop\n    bx(root, .004, .012, .006, ti, 0, .031, .094, .65);           \/\/ hammer\n    cy(root, .003, .01, gold, .0, -.005, .075).rotation.x = 0;    \/\/ main spring housing pin\n    bx(root, .0028, .0045, .002, steel, .0145, -.0085, .06);      \/\/ frame screws\n    bx(root, .0028, .0045, .002, steel, -.0145, -.0085, .06);\n    \/\/ ---- slide group (moves back and forward when shooting) ----\n    const slideG = new THREE.Group(); root.add(slideG);\n    bx(slideG, .029, .033, .185, purple, 0, .0305, -.0225);      \/\/ slide body\n    bx(slideG, .031, .006, .13, purpleD, 0, .018, -.05);         \/\/ lower step with the rail grooves\n    bx(slideG, .02, .004, .183, purple, 0, .0485, -.0225);       \/\/ flat top\n    \/\/ rear serrations\n    for (let i = 0; i < 6; i++) { bx(slideG, .0312, .028, .0022, purpleD, 0, .0305, .035 + i * .0065); }\n    \/\/ ports on top: three cut-outs and the open barrel window\n    bx(slideG, .014, .0012, .011, frame, 0, .0505, -.095); bx(slideG, .014, .0012, .011, frame, 0, .0505, -.079); bx(slideG, .014, .0012, .011, frame, 0, .0505, -.063);\n    bx(slideG, .013, .0012, .036, frame, 0, .0505, -.025);       \/\/ ejection area\n    bx(slideG, .005, .0045, .034, steel, 0, .0485, -.025);       \/\/ the barrel is visible inside it\n    \/\/ side cut-outs: front windows + ejection port\n    for (const sd of [-1, 1]) {\n      bx(slideG, .0012, .011, .012, frame, sd * .0147, .0335, -.098); bx(slideG, .0012, .011, .012, frame, sd * .0147, .0335, -.08); bx(slideG, .0012, .011, .012, frame, sd * .0147, .0335, -.062);\n      bx(slideG, .0008, .008, .04, purpleD, sd * .0149, .038, -.032); \/\/ slanted cut line\n    }\n    bx(slideG, .0013, .016, .038, frame, .0147, .036, -.012);    \/\/ ejection port (right side)\n    \/\/ chevron marks\n    bx(slideG, .01, .0006, .008, purpleD, -.006, .0494, .02); bx(slideG, .01, .0006, .008, purpleD, .006, .0494, .02);\n    \/\/ threaded barrel + flat-end compensator\n    cy(slideG, .0085, .03, steel, 0, .031, -.118);\n    bx(slideG, .021, .021, .036, purple, 0, .031, -.15);         \/\/ compensator block\n    bx(slideG, .023, .007, .02, purpleD, 0, .0425, -.152);       \/\/ top edge bevel\n    for (let i = 0; i < 3; i++) bx(slideG, .0225, .0016, .004, frame, 0, .0425 - .001, -.14 - i * .008); \/\/ compensator slits\n    cy(slideG, .0055, .003, frame, 0, .031, -.1685);             \/\/ bore\n    \/\/ sights: black base with green fibre optic dots at the back, red fibre in front\n    bx(slideG, .026, .007, .014, frame, 0, .0525, .055);\n    bx(slideG, .006, .009, .012, frame, -.0085, .0585, .057); bx(slideG, .006, .009, .012, frame, .0085, .0585, .057);\n    bx(slideG, .0016, .0016, .011, green, -.0085, .0638, .0575); bx(slideG, .0016, .0016, .011, green, .0085, .0638, .0575);\n    bx(slideG, .006, .004, .01, frame, 0, .0525, -.1);           \/\/ front sight base\n    bx(slideG, .004, .008, .004, red, 0, .0585, -.102);          \/\/ red fibre front sight\n    return slideG;\n  }\n  function buildGun(type) {\n    buildGun.slide = null;\n    while (gun.children.length) { const c = gun.children[0]; gun.remove(c); c.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); }\n    const mat = c => new THREE.MeshStandardMaterial({ color: c, roughness: .55, metalness: .3 });\n    const dark = mat(0x1f2329), mid = mat(0x3a4048), acc = new THREE.MeshStandardMaterial({ color: 0xff7a1a, roughness: .5 }); \/\/ orange tip, like real airsoft\n    const box = (w, h, d, m, x, y, z, rx) => { const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); k.position.set(x, y, z); if (rx) k.rotation.x = rx; gun.add(k); };\n    const cyl = (r, len, m, x, y, z) => { const k = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 14), m); k.position.set(x, y, z); k.rotation.x = Math.PI \/ 2; gun.add(k); };\n    if (type === 'm4') { \/\/ speedsoft rifle: carbon tube upper, skeleton lower, rear end block, AR style magazine, big grip with air bottle holder\n      const rifle = makeSpeedRifle(wcol); while (rifle.children.length) gun.add(rifle.children[0]); \/\/ the same model other players hold (see makeSpeedRifle)\n    } else if (type === 'pistol') { \/\/ hi-capa with a moving slide\n      buildGun.slide = makeHiCapa(gun, wcol);\n    }\n    const W = WEAPONS[type];\n    muzzle.position.set(W.muzzle[0], W.muzzle[1], W.muzzle[2]); gun.add(muzzle);\n    GUN_BASE.set(W.base[0], W.base[1], W.base[2]); gun.position.copy(GUN_BASE); gun.rotation.y = .05;\n  }\n  \/\/ Game rules per mode: may players change weapon while a game is running? (a future mode can set weaponSwitch: true)\n  const MODE_RULES = { free: { weaponSwitch: true }, match: { weaponSwitch: false }, buzzer: { weaponSwitch: false }, flag: { weaponSwitch: false }, flag7: { weaponSwitch: false } };\n  const matchOpen = () => !match.on || match.phase === 'over' || (match.phase === 'count' && match.t < 0); \/\/ between rounds, until the countdown starts\n  const canSwitchWeapon = () => matchOpen() || !!(MODE_RULES[$('.n-mode').value] || {}).weaponSwitch;\n  let wlT = 0;\n  function weaponLockMsg() { const n = performance.now(); if (n - wlT < 1500) return; wlT = n; setHud('\ud83d\udd12 You cannot switch weapons while a round is running.'); setTimeout(hint, 1500); }\n  function cycleWeapon(dir) {\n    const l = WLIST, i = Math.max(0, l.indexOf(weapon));\n    setWeapon(l[(i + dir + l.length) % l.length]);\n  }\n  function setWeapon(w, force) {\n    if (!WEAPONS[w] || !WLIST.includes(w)) return;\n    if (!force && !canSwitchWeapon()) { weaponLockMsg(); return; } \/\/ locked while a game is running\n    weapon = w; buildGun(w); fp.firing = false; fp.reloadT = 0; if (fp.ammo[w] <= 0) startReload();\n    $$('[data-wpn]').forEach(b => b.classList.toggle('on', b.dataset.wpn === w));\n    try { localStorage.setItem('pbf_weapon', w); } catch (e) {}\n  }\n  $$('[data-wpn]').forEach(b => { b.onclick = () => setWeapon(b.dataset.wpn); });\n  const ballSw = (i, extra) => '<button data-ball=\"' + i + '\" title=\"' + (i < 0 ? 'Team color' : BALLN[i]) + '\" class=\"' + (ballCi === i ? 'on ' : '') + (extra || '') + '\" style=\"' + (i < 0 ? '' : 'background:#' + BALLC[i].toString(16).padStart(6, '0') + ';') + (i === TRACER ? 'box-shadow:0 0 9px 2px #39ff5a;' : '') + 'min-width:34px;min-height:34px;padding:0;border-radius:50%\">' + (i < 0 ? '<small>Team<\/small>' : '') + '<\/button>';\n  const ballSwAll = () => [-1].concat(BALLC.map((c, i) => i)).map(i => ballSw(i)).join('');\n  function setBall(i) {\n    ballCi = i; try { localStorage.setItem('pbf_ball', String(i)); } catch (e) {}\n    $('.b-col').innerHTML = ballSwAll(); ws.sig = '';\n  }\n  $('.b-col').addEventListener('click', e => { const b = e.target.closest('[data-ball]'); if (b) setBall(+b.dataset.ball); });\n  function startReload() { \/\/ empty mag: reloading takes time and you cannot shoot\n    const W = WEAPONS[weapon];\n    if (fp.reloadT > 0 || fp.ammo[weapon] >= W.mag) return;\n    fp.reloadD = fp.reloadT = W.reload; fp.firing = false;\n  }\n  const amEl = $('.pbf-ammo'); let amTxt = '';\n  function updateAmmo() {\n    amEl.hidden = !fp.on; if (!fp.on) return;\n    const t = fp.reloadT > 0 ? 'Reloading\u2026 ' + fp.reloadT.toFixed(1) + 's' : (fp.ammo[weapon] + ' \/ ' + WEAPONS[weapon].mag);\n    const h = WNAME[weapon] + ' \u00b7 ' + t;\n    if (h !== amTxt) { amTxt = h; amEl.textContent = h; amEl.style.color = fp.reloadT > 0 ? '#ff7a1a' : '#fff'; }\n  }\n  const JUMP_V = 4.6, GRAV = 14, SLIDE_T = .85; \/\/ slide lasts 0.85 s \/\/ jump speed (m\/s) and gravity (m\/s\u00b2): about 0.75 m high\n  function swapHand() { \/\/ X: hold the marker in the other hand\n    fp.hand = -fp.hand;\n    try { localStorage.setItem('pbf_hand', String(fp.hand)); } catch (e) {}\n  }\n  function syncGun(dt) {\n    gunRig.position.copy(camera.position); gunRig.quaternion.copy(camera.quaternion);\n    fp.recoil = Math.max(0, fp.recoil - dt * 14);\n    if (fp.moving) fp.bob += dt * (fp.keys.ShiftLeft || fp.keys.ShiftRight ? 12 : 8);\n    const bob = fp.moving ? Math.sin(fp.bob) * .004 : 0, bobx = fp.moving ? Math.cos(fp.bob * .5) * .003 : 0;\n    fp.handS += (fp.hand - fp.handS) * Math.min(1, dt * 14); \/\/ smooth slide to the other side\n    const rl = fp.reloadT > 0 ? Math.sin(Math.PI * (1 - fp.reloadT \/ fp.reloadD)) : 0; \/\/ gun dips while reloading\n    gun.position.set(GUN_BASE.x * fp.handS + bobx, GUN_BASE.y + bob - rl * .12, GUN_BASE.z + fp.recoil * .035);\n    gun.rotation.x = fp.recoil * .05 + rl * .6; gun.rotation.y = .05 * fp.handS; gun.rotation.z = 0;\n    const gd = gunDown(); \/\/ start of a round: the tip of the marker rests on the start zone, then it swings up in front of you\n    if (gd !== 0) { gun.position.y -= gd * .24; gun.position.z += gd * .06; gun.rotation.x -= gd * 1.05; gun.rotation.y -= gd * .12 * fp.handS; }\n    if (buildGun.slide) { \/\/ pistol slide: snaps back when a shot goes off and returns; stays back on an empty magazine (slide lock)\n      fp.slideK = Math.max(0, (fp.slideK || 0) - dt * 9);\n      const k = fp.slideK, f = k > .7 ? (1 - k) \/ .3 : k \/ .7;\n      buildGun.slide.position.z = .03 * (fp.ammo.pistol <= 0 ? 1 : f);\n    }\n    gunRig.updateMatrixWorld(true);\n  }\n\n  const BALL_SPEED = 70, BALL_G = 4, BALL_R = .03, MAX_BALLS = 300, MAX_SPLATS = 220;\n  const PAINT = [0xe5484d, 0x3b82f6]; \/\/ 0 = Red team, 1 = Blue team\n  const ballGeo = new THREE.SphereGeometry(BALL_R, 10, 8), splatGeo = new THREE.CircleGeometry(1, 18);\n  \/\/ paint colors: 0 \/ 1 = the team colors, the rest are free to choose in the weapon menu (-1 = use your team color)\n  const BALLC = [0xe5484d, 0x3b82f6, 0xffd23f, 0x2ecc71, 0xff8a1f, 0xff5fb0, 0xb06cff, 0x22d3ee, 0xf5f5f5, 0x1b1b1f, 0x39ff5a], TRACER = 10, BALLN = ['Red', 'Blue', 'Yellow', 'Green', 'Orange', 'Pink', 'Purple', 'Cyan', 'White', 'Black', 'Green tracer (glowing)'];\n  const tracerGeo = new THREE.CylinderGeometry(.011, .011, .32, 6).rotateX(Math.PI \/ 2), tracerMat = new THREE.MeshBasicMaterial({ color: 0x7dff8f, transparent: true, opacity: .95, blending: THREE.AdditiveBlending, depthWrite: false }); \/\/ glowing streak\n  let ballCi = TRACER; try { const v = parseInt(localStorage.getItem('pbf_ball'), 10); if (v >= -1 && v < BALLC.length) ballCi = v; } catch (e) {}\n  $('.b-col').innerHTML = ballSwAll();\n  const ballMat = BALLC.map(c => new THREE.MeshStandardMaterial({ color: c, roughness: .35, emissive: c, emissiveIntensity: .25 }));\n  const splatMat = BALLC.map(c => new THREE.MeshBasicMaterial({ color: c, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }));\n  const balls = [], splats = [], rc = new THREE.Raycaster();\n  const bodiesOf = () => bunkers.map(b => b.body);\n\n  let splatOn = true; try { splatOn = localStorage.getItem('pbf_splat') !== '0'; } catch (e) {}\n  function addSplat(p, n, ci, mul) {\n    if (!splatOn) return;\n    const m = new THREE.Mesh(splatGeo, splatMat[ci]);\n    const ground = n.y > .99 && p.y < .12;\n    if (ground) { m.position.set(p.x, .08, p.z); m.rotation.x = -Math.PI \/ 2; }\n    else { m.position.copy(p).addScaledVector(n, .012); m.lookAt(p.clone().add(n)); }\n    m.rotateZ(Math.random() * 6.283);\n    const s = (.02 + Math.random() * .02) * (mul || 1); \/* small hit mark: radius 2-4 cm *\/ m.scale.set(s, s * (.65 + Math.random() * .45), 1);\n    scene.add(m); splats.push(m);\n    if (splats.length > MAX_SPLATS) scene.remove(splats.shift());\n  }\n  function clearShots() {\n    while (balls.length) scene.remove(balls.pop().mesh);\n    while (splats.length) scene.remove(splats.pop());\n  }\n  function launch(o, v, ci, own) {\n    const mesh = ci === TRACER ? new THREE.Mesh(tracerGeo, tracerMat) : new THREE.Mesh(ballGeo, ballMat[ci]); mesh.position.copy(o);\n    if (ci === TRACER) mesh.lookAt(o.x + v.x, o.y + v.y, o.z + v.z);\n    scene.add(mesh);\n    balls.push({ mesh, v, ci, age: 0, own });\n    if (balls.length > MAX_BALLS) scene.remove(balls.shift().mesh);\n  }\n  function shoot() {\n    if (match.on && (match.phase !== 'live' || match.rdy < 1 || (me() && me().out))) return; \/\/ locked during countdown \/ between rounds \/ when you are out \/ while the marker is still being brought up after GO\n    if (fp.reloadT > 0) return; \/\/ reloading: cannot shoot\n    const WP = WEAPONS[weapon];\n    if (fp.ammo[weapon] <= 0) { startReload(); return; }\n    fp.ammo[weapon]--; fp.cool = WP.cool; fp.recoil = WP.rec; if (buildGun.slide) fp.slideK = 1; if (!net.guest) stat(net.pid).s++; \/\/ shots fired (guests are counted by the host)\n    if (fp.ammo[weapon] <= 0) startReload(); \/\/ last round fired: reload\n    camera.updateMatrixWorld(true); syncGun(0);\n    const o = new THREE.Vector3(); muzzle.getWorldPosition(o);\n    const f = new THREE.Vector3(); camera.getWorldDirection(f);\n    const bodies = bodiesOf(), ci = ballCi >= 0 ? ballCi : net.team; \/\/ projectile color = the one you picked (default: your team color)\n    \/\/ point the ball at what the crosshair is on, so shots land where you aim\n    rc.set(camera.position, f); rc.far = 80;\n    const h = rc.intersectObjects(bodies, true)[0];\n    const tgt = h ? h.point.clone() : camera.position.clone().addScaledVector(f, 60);\n    \/\/ if the barrel is poking into a bunker, the ball bursts right there\n    const toM = o.clone().sub(camera.position), dM = toM.length();\n    if (dM > .001) {\n      rc.set(camera.position, toM.clone().divideScalar(dM)); rc.far = dM;\n      const hm = rc.intersectObjects(bodies, true)[0];\n      if (hm) { const nn = hm.face.normal.clone().transformDirection(hm.object.matrixWorld).normalize(); addSplat(hm.point, nn, ci, 1); return; }\n    }\n    const dir = tgt.sub(o).normalize();\n    const spr = WP.spread;\n    dir.x += (Math.random() - .5) * spr; dir.y += (Math.random() - .5) * spr; dir.z += (Math.random() - .5) * spr; dir.normalize();\n    const v = dir.multiplyScalar(WP.speed);\n    launch(o.clone(), v.clone(), ci, true);\n    net.send({ t: 's', o: [+o.x.toFixed(3), +o.y.toFixed(3), +o.z.toFixed(3)], v: [+v.x.toFixed(2), +v.y.toFixed(2), +v.z.toFixed(2)], ci, f: net.pid });\n  }\n  const _o = new THREE.Vector3(), _s = new THREE.Vector3();\n  function updateBalls(dt) {\n    if (!balls.length) return;\n    const bodies = bodiesOf();\n    for (let i = balls.length - 1; i >= 0; i--) {\n      const b = balls[i], p = b.mesh.position;\n      b.age += dt; b.v.y -= BALL_G * dt;\n      _o.copy(p); _s.copy(b.v).multiplyScalar(dt);\n      const len = _s.length();\n      let dead = b.age > 4;\n      if (!dead && len > 0) {\n        rc.set(_o, _s.clone().divideScalar(len)); rc.far = len;\n        const h = bodies.length ? rc.intersectObjects(bodies, true)[0] : null;\n        if (h) {\n          const nn = h.face.normal.clone().transformDirection(h.object.matrixWorld).normalize();\n          addSplat(h.point, nn, b.ci, 1); dead = true;\n        }\n        else if (b.own && (!match.on || match.phase === 'live')) {\n          const v = enemyHit(_o, _s);\n          if (v) { dead = true; myHit(v.pid); }\n        }\n      }\n      if (!dead) {\n        p.add(_s);\n        if (p.y <= BALL_R) { addSplat(new THREE.Vector3(p.x, 0, p.z), new THREE.Vector3(0, 1, 0), b.ci, 1); dead = true; }\n        else if (Math.abs(p.x) > W \/ 2 || Math.abs(p.z) > L \/ 2 || (indoor && p.y > HALL_H)) dead = true; \/\/ stopped by the net (and the roof indoors)\n      }\n      if (dead) { scene.remove(b.mesh); balls.splice(i, 1); }\n    }\n  }\n\n  \/* ---------- first-person walk ---------- *\/\n  const xh = $('.pbf-xh');\n  function enterWalk() {\n    if (net.coach) { setHud('\ud83d\udc41 Spectators do not walk. Drag on the field to draw marks.'); return; }\n    if (!ncon()) stats.clear(); \/\/ a new solo session starts with a clean scoreboard\n    fp.on = true; fp.keys = {}; fp.last = 0; fp.lean = 0; fp.firing = false; fp.cool = 0; fp.recoil = 0; fp.moving = false;\n    clearShots();\n    placeAtStart(); \/\/ host: red zone facing blue; guest: blue zone facing red\n    camera.rotation.order = 'YXZ';\n    ring.visible = false; xh.hidden = false;\n    $('.f-walk').classList.add('on'); $('.f-walk').textContent = '\ud83d\udeb6 Exit walk'; netWalk(true);\n    touchSync(); hint();\n  }\n  function exitWalk(keep) { fp.prone = false; \/\/ keep = leave the field without ending the match (switching to spectator mode)\n    if (!fp.on) return;\n    if (keep) { \/* the match goes on *\/ } else if (net.host) stopMatch(true); else if (match.on && me()) { me().out = true; }\n    fp.on = false; fp.drag = null; fp.firing = false; if (wt.g) watchBody(wt.g, false); wt.on = false; wt.g = null; wbanner.hidden = true;\n    camera.fov = 50; camera.updateProjectionMatrix(); \/\/ the orbit view keeps its own angle\n    if (document.pointerLockElement === canvas) document.exitPointerLock();\n    xh.hidden = true; ring.visible = !!sel && mode === 'build';\n    clearShots();\n    $('.f-walk').classList.remove('on'); $('.f-walk').textContent = '\ud83d\udeb6 Walk'; netWalk(false);\n    camera.rotation.order = 'XYZ'; camera.fov = fovV(); camera.updateProjectionMatrix(); gunRig.visible = true; view('iso'); touchSync(); hint();\n  }\n  $('.f-walk').onclick = () => fp.on ? exitWalk() : enterWalk();\n\n  \/\/ Between rounds (same window in which weapon \/ team \/ role can be changed) the mouse is given back, and it is captured again when the countdown starts.\n  let mouseFree = false;\n  function mouseSync() {\n    if (!fp.on || !match.on) { mouseFree = false; return; }\n    const open = matchOpen();\n    if (open && !mouseFree) { mouseFree = true; if (document.pointerLockElement === canvas) document.exitPointerLock(); }\n    else if (!open && mouseFree) {\n      mouseFree = false;\n      if (!touchMode && canvas.requestPointerLock && !fp.noLock && document.pointerLockElement !== canvas) {\n        try { const r = canvas.requestPointerLock(); if (r && r.catch) r.catch(() => {}); } catch (e) {}\n        setTimeout(() => { if (fp.on && match.on && !touchMode && !fp.noLock && document.pointerLockElement !== canvas) setHud('\ud83d\uddb1 Click the field to capture the mouse again.'); }, 400); \/\/ some browsers need a click to lock the mouse\n      }\n    }\n  }\n  let sensK = 1; try { const q = +localStorage.getItem('pbf_sens'); if (q >= .2 && q <= 3) sensK = q; } catch (e) {}\n  function lookBy(dx, dy) {\n    const z = camera.fov \/ fovV(); \/\/ slower turning while zoomed in\n    fp.yaw -= dx * .0022 * z; fp.pitch = cl(fp.pitch - dy * .0022 * z, -1.4, 1.4);\n  }\n  \/\/ The mouse lock: the browser refuses a new lock for about a second after Esc, so one failed click must never switch mouse look off for good.\n  \/\/ Only when the lock has never worked in this session (e.g. a page that is not allowed to lock the mouse) the drag-to-look fallback is used.\n  document.addEventListener('pointerlockchange', () => { if (document.pointerLockElement === canvas) { fp.lockedOnce = true; fp.noLock = false; fp.skipMv = 3; } else fp.unlockAt = performance.now(); });\n  document.addEventListener('pointerlockerror', () => {\n    if (fp.lockedOnce || performance.now() - (fp.unlockAt || -1e9) < 4000) { if (fp.on && !touchMode) { setHud('\ud83d\uddb1 Click the field again to take back the mouse.'); setTimeout(hint, 2500); } return; }\n    fp.noLock = true;\n  });\n  canvas.addEventListener('pointerdown', e => {\n    if (!fp.on) return;\n    if (e.button === 2) { if (!wt.on) swapHand(); return; } \/\/ right mouse button: hold the marker in the other hand\n    if (e.pointerType === 'touch' && !touchMode) { touchMode = true; $('.f-touch').checked = true; touchSync(); hint(); }\n    if (touchMode) { \/\/ touch: swipe to look, shooting is done with the FIRE button\n      fp.drag = { id: e.pointerId, x: e.clientX, y: e.clientY };\n      try { canvas.setPointerCapture(e.pointerId); } catch (er) {}\n      return;\n    }\n    if (mouseFree) return; \/\/ between rounds the mouse stays free so settings can be changed\n    if (canvas.requestPointerLock && !fp.noLock && document.pointerLockElement !== canvas) {\n      \/\/ first click only captures the mouse\n      try { const r = canvas.requestPointerLock(); if (r && r.catch) r.catch(() => {}); } catch (er) {}\n    } else {\n      if (mode === 'quiz' && quiz.on && quiz.type === 'find') {\n        \/\/ while the mouse is captured, \"click\" what is under the crosshair\n        ray.setFromCamera(new THREE.Vector2(0, 0), camera);\n        const b = hitBunker(); if (b) answer(b.name === quiz.target.name, b);\n      }\n      fp.firing = WEAPONS[weapon].auto; \/\/ rifle: hold to keep firing, pistol: one shot per click\n      if (fp.cool <= 0) shoot();\n    }\n    \/\/ drag-to-look fallback (touch, or when pointer lock is unavailable)\n    fp.drag = { id: e.pointerId, x: e.clientX, y: e.clientY };\n    try { canvas.setPointerCapture(e.pointerId); } catch (er) {}\n  });\n  canvas.addEventListener('pointermove', e => {\n    if (!fp.on || !fp.drag || fp.drag.id !== e.pointerId || document.pointerLockElement === canvas) return;\n    const k = touchMode ? 1.5 : sensK; lookBy((e.clientX - fp.drag.x) * k, (e.clientY - fp.drag.y) * k); fp.drag.x = e.clientX; fp.drag.y = e.clientY;\n  });\n  canvas.addEventListener('pointerup', e => { fp.drag = null; fp.firing = false; });\n  canvas.addEventListener('pointercancel', () => { fp.drag = null; fp.firing = false; });\n  document.addEventListener('mousedown', e => { \/\/ shoot with the left button while the right button is held\n    if (!fp.on || touchMode || (e.target !== canvas && document.pointerLockElement !== canvas)) return;\n    if (e.button === 0 && (e.buttons & 2)) { \/\/ left click while the right button is held: no pointerdown is sent for it\n      fp.firing = WEAPONS[weapon].auto; if (fp.cool <= 0) shoot();\n    }\n  });\n  document.addEventListener('mouseup', e => { if (e.button === 0) fp.firing = false; });\n  document.addEventListener('mousedown', e => { \/\/ watching a teammate: left click = next, right click = previous\n    if (!wt.on || touchMode || (e.target !== canvas && document.pointerLockElement !== canvas)) return;\n    if (e.button === 0) watchStep(1); else if (e.button === 2) watchStep(-1);\n  });\n  canvas.addEventListener('pointerdown', e => { if (wt.on && e.pointerType === 'touch') watchStep(1); }); \/\/ touch: tap = next teammate\n  document.addEventListener('mousemove', e => {\n    if (fp.on && document.pointerLockElement === canvas) {\n      \/\/ Some browsers \/ mice (high polling rate, first move after the lock) report one absurd movement value: a sudden 180\u00b0 turn. Those spikes are thrown away.\n      const mx = e.movementX || 0, my = e.movementY || 0;\n      if (fp.skipMv > 0) { fp.skipMv--; return; }\n      if (Math.abs(mx) > 600 || Math.abs(my) > 600) return; \/\/ 600 px in a single event would be a turn of about 75\u00b0: not a human movement\n      lookBy(mx * sensK, my * sensK);\n    }\n  });\n  const kbEl = $('.kb-list'); let kbWait = null;\n  const BADKEYS = \/^(Escape|Tab|Digit[1-5]|Arrow(Up|Down|Left|Right)|MetaLeft|MetaRight|ContextMenu|F\\d+)$\/;\n  function kbRender() {\n    kbEl.innerHTML = ACTS.map(a => '<span>' + a[1] + '<\/span><button data-act=\"' + a[0] + '\" class=\"' + (kbWait === a[0] ? 'wait' : '') + '\">' + (kbWait === a[0] ? 'press a key\u2026' : kname(binds[a[0]])) + '<\/button>').join('');\n  }\n  function kbSave() { try { localStorage.setItem('pbf_keys2', JSON.stringify(binds)); } catch (e) {} }\n  kbEl.addEventListener('click', e => { const b = e.target.closest('[data-act]'); if (!b) return; kbWait = kbWait === b.dataset.act ? null : b.dataset.act; kbRender(); });\n  $('.kb-reset').onclick = () => { ACTS.forEach(a => { binds[a[0]] = a[2]; }); kbWait = null; kbSave(); kbRender(); };\n  window.addEventListener('keydown', e => { \/\/ capture the next key press for the action being changed\n    if (!kbWait) return;\n    e.preventDefault(); e.stopImmediatePropagation();\n    if (e.code === 'Escape') { kbWait = null; kbRender(); return; }\n    if (BADKEYS.test(e.code)) return;\n    const id = kbWait, old = binds[id];\n    const other = ACTS.find(a => a[0] !== id && (binds[a[0]] === e.code || (id === 'run' && isShift(e.code) && isShift(binds[a[0]])) || (a[0] === 'run' && isShift(binds[a[0]]) && isShift(e.code))));\n    if (other) binds[other[0]] = old; \/\/ the key was in use: the two actions swap keys\n    binds[id] = e.code; kbWait = null; kbSave(); kbRender();\n    if (fp.on) fp.keys = {};\n  }, true);\n  kbRender();\n  document.addEventListener('keydown', e => {\n    if (!fp.on || \/INPUT|SELECT|TEXTAREA\/.test((e.target.tagName || ''))) return;\n    const ic = canon(e.code); if (ic && ic !== 'KeyC') fp.keys[ic] = true;\n    if (ic === 'KeyV' && ws.on && !e.repeat) wsSpectate();\n    if (ic === 'KeyT' && ws.on && !e.repeat) wsTeam();\n    if (e.code === 'Tab' && ws.on) { e.preventDefault(); if (document.pointerLockElement === canvas) document.exitPointerLock(); } \/\/ free the mouse to click a weapon\n    if (ic === 'KeyP' && !e.repeat) toggleProne();\n    if (ic === 'KeyC' && !e.repeat) { fp.keys.KeyC = !fp.keys.KeyC; if (fp.keys.KeyC) { fp.crouchPress = true; if (fp.prone) setProne(false); } } \/\/ crouch while lying: get up into a crouch \/\/ crouch pressed (checked for a slide in walkStep)\n    if (ic === 'Space' && !e.repeat) fp.jumpReq = true; \/\/ one jump per key press: holding the key does not keep jumping\n    if (ic === 'KeyR' && !e.repeat) startReload();\n    if (ic === 'KeyZ') clearShots();\n    if (\/^Digit[1-5]$\/.test(e.code)) { const n = +e.code[5]; if (ws.on && !e.shiftKey) { if (!e.repeat) wsSlot(n); } else setWeapon(WLIST[n - 1]); } \/\/ in the between-rounds menu 1-5 pick a start spot, Shift+1-5 a weapon\n    if (ic === 'KeyF' && !e.repeat) toggleFs();\n    if (ic === 'KeyG' && !e.repeat) flagKey();\n    if (ic && !e.ctrlKey && !e.metaKey && !e.altKey) e.preventDefault();\n  });\n  document.addEventListener('keyup', e => { const ic = canon(e.code); if (ic && ic !== 'KeyC') fp.keys[ic] = false; });\n  \/* While walking, the browser must not react to ANY key or mouse button (Opera, Edge, Firefox and Chrome all have their own hotkeys, mouse gestures and back\/forward buttons).\n     The listeners run first (capture) and only cancel the browser's default action: the game still receives the keys. Esc stays free to give the mouse back, F11 \/ F12 keep working. *\/\n  const KEEP_KEYS = \/^(Escape|F11|F12)$\/;\n  const typing = t => \/INPUT|SELECT|TEXTAREA\/.test((t && t.tagName) || '') || !!(t && t.isContentEditable);\n  ['keydown', 'keyup', 'keypress'].forEach(ev => window.addEventListener(ev, e => {\n    if (!fp.on || typing(e.target) || KEEP_KEYS.test(e.code || e.key || '')) return;\n    e.preventDefault();\n  }, { capture: true, passive: false }));\n  ['mousedown', 'mouseup', 'auxclick', 'pointerdown', 'pointerup'].forEach(ev => window.addEventListener(ev, e => {\n    if (!fp.on || touchMode || e.pointerType === 'touch') return;\n    if (e.button === 1 || e.button === 3 || e.button === 4) { e.preventDefault(); return; } \/\/ middle click (autoscroll) and the mouse's back \/ forward buttons\n    if (e.button === 2 && e.target === canvas) e.preventDefault(); \/\/ right button (Opera mouse gestures)\n  }, { capture: true, passive: false }));\n  window.addEventListener('wheel', e => { if (fp.on && (e.ctrlKey || e.target === canvas)) e.preventDefault(); }, { capture: true, passive: false }); \/\/ no page scroll \/ zoom\n  window.addEventListener('beforeunload', e => { if (fp.on && (net.host || net.guest)) { e.preventDefault(); e.returnValue = ''; } }); \/\/ an accidental refresh \/ back would drop you out of the game\n  document.addEventListener('fullscreenchange', () => { \/\/ Chrome \/ Edge \/ Opera: in fullscreen the page may take over the keys the browser normally keeps (Alt+Tab etc. stay with the OS)\n    try {\n      if (!navigator.keyboard) return;\n      if (document.fullscreenElement) { const k = []; for (let c = 65; c <= 90; c++) k.push('Key' + String.fromCharCode(c)); for (let d = 0; d <= 9; d++) k.push('Digit' + d); ['Tab', 'Space', 'Enter', 'Backspace', 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ContextMenu'].forEach(x => k.push(x)); for (let n = 1; n <= 10; n++) k.push('F' + n); navigator.keyboard.lock(k).catch(() => {}); }\n      else navigator.keyboard.unlock();\n    } catch (er) {}\n  });\n  window.addEventListener('blur', () => { fp.keys = {}; fp.firing = false; });\n\n  \/* ---------- touch controls (virtual joysticks) ---------- *\/\n  let touchMode = !!(window.matchMedia && matchMedia('(pointer: coarse)').matches);\n  const tEl = $('.pbf-touch'), tc = { mx: 0, my: 0, lx: 0, ly: 0 };\n  const cv = v => Math.sign(v) * Math.pow(Math.abs(v), 1.6); \/\/ gentle curve: fine aim near the center\n  function stick(el, cb) {\n    const knob = el.firstElementChild; let id = null, cx = 0, cy = 0, R = 1;\n    function upd(e) {\n      let dx = e.clientX - cx, dy = e.clientY - cy; const m = Math.hypot(dx, dy);\n      if (m > R) { dx *= R \/ m; dy *= R \/ m; }\n      knob.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';\n      const x = dx \/ R, y = dy \/ R;\n      if (Math.hypot(x, y) < .12) cb(0, 0); else cb(x, y); \/\/ dead zone\n    }\n    function end(e) { if (e && e.pointerId !== id) return; id = null; knob.style.transform = ''; cb(0, 0); }\n    el.addEventListener('pointerdown', e => {\n      e.preventDefault(); id = e.pointerId;\n      try { el.setPointerCapture(id); } catch (er) {}\n      const r = el.getBoundingClientRect(); cx = r.left + r.width \/ 2; cy = r.top + r.height \/ 2; R = r.width * .36; upd(e);\n    });\n    el.addEventListener('pointermove', e => { if (e.pointerId === id) upd(e); });\n    ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(n => el.addEventListener(n, end));\n  }\n  function hold(el, on, off) {\n    el.addEventListener('pointerdown', e => { e.preventDefault(); try { el.setPointerCapture(e.pointerId); } catch (er) {} el.classList.add('down'); on(); });\n    const up = () => { el.classList.remove('down'); off(); };\n    ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(n => el.addEventListener(n, up));\n  }\n  const tap = (el, fn) => el.addEventListener('pointerdown', e => { e.preventDefault(); fn(e); });\n  stick($('.tj-l'), (x, y) => { tc.mx = x; tc.my = -y; });\n  stick($('.tj-r'), (x, y) => { tc.lx = x; tc.ly = y; });\n  hold($('.tb-fire'), () => {\n    if (mode === 'quiz' && quiz.on && quiz.type === 'find') { \/\/ answer by aiming at a bunker\n      ray.setFromCamera(new THREE.Vector2(0, 0), camera);\n      const b = hitBunker(); if (b) answer(b.name === quiz.target.name, b);\n    }\n    fp.firing = WEAPONS[weapon].auto; if (fp.cool <= 0) shoot();\n  }, () => { fp.firing = false; });\n  hold($('.tb-q'), () => { fp.keys.KeyQ = true; }, () => { fp.keys.KeyQ = false; });\n  hold($('.tb-e'), () => { fp.keys.KeyE = true; }, () => { fp.keys.KeyE = false; });\n  tap($('.tb-p'), () => toggleProne());\n  tap($('.tb-c'), e => { fp.keys.KeyC = !fp.keys.KeyC; if (fp.keys.KeyC) fp.crouchPress = true; e.currentTarget.classList.toggle('on', !!fp.keys.KeyC); });\n  tap($('.tb-w'), () => cycleWeapon(1));\n  tap($('.tb-g'), () => flagKey());\n  tap($('.tb-h'), () => swapHand());\n  tap($('.tb-j'), () => { fp.jumpReq = true; });\n  tap($('.tb-fs'), () => toggleFs());\n  tap($('.tb-x'), () => exitWalk());\n  tEl.addEventListener('contextmenu', e => e.preventDefault());\n  function touchSync() {\n    const on = fp.on && touchMode;\n    tEl.hidden = !on; stage.classList.toggle('touch-on', on);\n    if (!on) {\n      tc.mx = tc.my = tc.lx = tc.ly = 0; fp.keys.KeyQ = fp.keys.KeyE = fp.keys.KeyC = false;\n      tEl.querySelectorAll('.tj-k').forEach(k => { k.style.transform = ''; });\n      $('.tb-c').classList.remove('on');\n    }\n  }\n  $('.f-touch').checked = touchMode;\n  $('.f-fov').value = fovH; $('.f-fovv').textContent = fovH + '\u00b0';\n  $('.f-fov').oninput = e => { fovH = cl(+e.target.value || 80, 50, 100); $('.f-fovv').textContent = fovH + '\u00b0'; try { localStorage.setItem('pbf_fov', String(fovH)); } catch (er) {} if (fp.on) { camera.fov = fovV(); camera.updateProjectionMatrix(); } };\n  $('.f-sens').value = Math.round(sensK * 100); $('.f-sensv').textContent = Math.round(sensK * 100) + '%';\n  $('.f-sens').oninput = e => { sensK = cl((+e.target.value || 100) \/ 100, .2, 3); $('.f-sensv').textContent = Math.round(sensK * 100) + '%'; try { localStorage.setItem('pbf_sens', String(sensK)); } catch (er) {} };\n  $('.f-edge').checked = edgesOn;\n  $('.f-edge').onchange = e => { edgesOn = e.target.checked; try { localStorage.setItem('pbf_edge2', edgesOn ? '1' : '0'); } catch (er) {} bunkers.forEach(buildStrips); };\n  { \/\/ weapon colours: pick, save, show on your own marker and tell the other players\n    const wcShow = () => $$('.wc-pick').forEach(el => { el.value = wcol[el.dataset.k]; });\n    const wcApply = () => {\n      try { localStorage.setItem('pbf_wcol', JSON.stringify(wcol)); } catch (er) {}\n      buildGun(weapon); \/\/ your own marker in first person\n      clearTimeout(wcApply.t); wcApply.t = setTimeout(() => { if (net.host) sendRoster(); else if (net.guest && ncon()) net.send({ t: 'wc', w: wcArr(wcol) }); }, 300); \/\/ dragging the colour picker sends many changes: only the last one goes out\n    };\n    wcShow();\n    $$('.wc-pick').forEach(el => { el.oninput = () => { if (okHex(el.value)) { wcol[el.dataset.k] = el.value.toLowerCase(); wcApply(); } }; });\n    $('.wc-reset').onclick = () => { WCK.forEach(k => { wcol[k] = WC_DEF[k]; }); wcShow(); wcApply(); };\n  }\n  $('.f-splat').checked = splatOn;\n  $('.f-splat').onchange = e => { splatOn = e.target.checked; if (!splatOn) while (splats.length) scene.remove(splats.pop()); try { localStorage.setItem('pbf_splat', splatOn ? '1' : '0'); } catch (er) {} };\n  $('.f-touch').onchange = e => { touchMode = e.target.checked; touchSync(); hint(); };\n\n  \/* ---------- game controller (Gamepad API: Xbox \/ PlayStation \"standard\" layout) ---------- *\/\n  const gp = { on: false, mx: 0, my: 0, lx: 0, ly: 0, sprint: false, prev: {}, startT: 0 };\n  const gpDead = (x, y) => { const m = Math.hypot(x, y); if (m < .15) return [0, 0]; const k = Math.min(1, (m - .15) \/ .85) \/ m; return [x * k, y * k]; };\n  function gpStatus(pad) { $('.gp-st').textContent = pad ? '\ud83c\udfae Connected: ' + String(pad.id).slice(0, 60) : '\ud83c\udfae No controller detected. Connect a PlayStation or Xbox controller (USB or Bluetooth) and press any button.'; }\n  function gpOff() {\n    if (gp.sb) { gp.sb = false; sbShow(false); }\n    gp.on = false; gp.mx = gp.my = gp.lx = gp.ly = 0; gp.sprint = false; gp.prev = {}; gp.startT = 0;\n    fp.keys.KeyQ = fp.keys.KeyE = fp.keys.KeyC = false; gpStatus(null);\n  }\n  function rumble(ms, strong, weak) {\n    try { const l = navigator.getGamepads(); for (let i = 0; i < l.length; i++) { const p = l[i]; if (p && p.connected && p.vibrationActuator) { p.vibrationActuator.playEffect('dual-rumble', { duration: ms, strongMagnitude: strong, weakMagnitude: weak }); break; } } } catch (e) {}\n  }\n  function gpPoll(dt) {\n    let pad = null;\n    try { const l = navigator.getGamepads ? navigator.getGamepads() : []; for (let i = 0; i < l.length; i++) if (l[i] && l[i].connected) { pad = l[i]; break; } } catch (e) {}\n    if (!pad) { if (gp.on) gpOff(); return; }\n    if (!gp.on) { gp.on = true; gpStatus(pad); }\n    { const bk = !!(pad.buttons[8] && pad.buttons[8].pressed); if (bk !== !!gp.sb) { gp.sb = bk; sbShow(bk); } } \/\/ Back \/ Select: hold = scoreboard\n    const bt = i => { const b = pad.buttons[i]; return !!b && (b.pressed || b.value > .5); };\n    const st = i => { const d = bt(i), p = !!gp.prev[i]; gp.prev[i] = d; return d ? (p ? 2 : 1) : (p ? -1 : 0); }; \/\/ 1 pressed now, 2 held, -1 released\n    const sT = st(9); \/\/ Start \/ Options: tap = walk, hold = stop walking\n    if (!fp.on) {\n      gp.mx = gp.my = gp.lx = gp.ly = 0; gp.sprint = false; gp.startT = 0;\n      if (sT === 1) { enterWalk(); gp.startT = -1e9; } \/\/ keep holding Start after entering: no instant exit\n      return;\n    }\n    if (sT === 1) gp.startT = 0;\n    if (sT > 0) { gp.startT += dt; if (gp.startT >= .8) { gp.startT = -1e9; exitWalk(); return; } } else gp.startT = 0;\n    const Lk = gpDead(pad.axes[0] || 0, pad.axes[1] || 0), Rk = gpDead(pad.axes[2] || 0, pad.axes[3] || 0);\n    gp.mx = Lk[0]; gp.my = -Lk[1]; gp.lx = Rk[0]; gp.ly = Rk[1]; gp.sprint = bt(10);\n    const f = st(7); \/\/ R2 \/ RT = shoot\n    if (f === 1) {\n      if (mode === 'quiz' && quiz.on && quiz.type === 'find') { ray.setFromCamera(new THREE.Vector2(0, 0), camera); const b = hitBunker(); if (b) answer(b.name === quiz.target.name, b); }\n      fp.firing = WEAPONS[weapon].auto; if (fp.cool <= 0) shoot();\n    } else if (f === -1) fp.firing = false;\n    if (st(0) === 1) fp.jumpReq = true;\n    const c = st(1); if (c === 1) { fp.keys.KeyC = !fp.keys.KeyC; if (fp.keys.KeyC) fp.crouchPress = true; }\n    const q = st(4); if (q === 1) fp.keys.KeyQ = true; else if (q === -1) fp.keys.KeyQ = false;\n    const r = st(5); if (r === 1) fp.keys.KeyE = true; else if (r === -1) fp.keys.KeyE = false;\n    if (st(3) === 1) cycleWeapon(1);\n    if (st(11) === 1) { if (ws.on) wsSpectate(); else toggleProne(); } \/\/ R3: spectate in the weapon menu, otherwise lie down \/ stand up\n    if (st(14) === 1) cycleWeapon(-1);\n    if (st(15) === 1) cycleWeapon(1);\n    if (st(2) === 1) { if (ws.on) wsTeam(); else flagKey(); } \/\/ X: switch team in the weapon menu, otherwise hand over the flag\n    if (st(12) === 1) { if (ws.on) wsSlotStep(1); else swapHand(); } \/\/ D-pad up \/ down in the menu: next \/ previous free start spot\n    if (st(13) === 1) { if (ws.on) wsSlotStep(-1); else clearShots(); }\n  }\n  window.addEventListener('gamepadconnected', e => { gpStatus(e.gamepad); setHud('\ud83c\udfae Controller connected. Tap <b>Start \/ Options<\/b> to walk.'); setTimeout(hint, 3500); });\n  window.addEventListener('gamepaddisconnected', () => { gpOff(); });\n\n  function leanBlocked(x, z, y) {\n    if (Math.abs(x) > W \/ 2 - .15 || Math.abs(z) > L \/ 2 - .15) return true;\n    return bunkers.some(b => {\n      const m = dimOf(b); if (m.h < y - .2) return false; \/\/ low bunkers can be peeked over\n      const ps = polysOf(b), c = Math.cos(b.rot), s = Math.sin(b.rot), dx = x - b.x, dz = z - b.z;\n      if (Math.hypot(dx, dz) > b.cr + .12) return false;\n      const lx = dx * c - dz * s, lz = dx * s + dz * c;\n      return ps.some(poly => circlePoly(poly, lx, lz, .12));\n    });\n  }\n  function setProne(v) { \/\/ lie down \/ stand up\n    fp.prone = !!v; fp.proneCd = .35; const b = $('.tb-p'); if (b) b.classList.toggle('on', fp.prone);\n  }\n  function toggleProne() { if (!fp.on || fp.slide || fp.y > 0 || net.coach) return; setProne(!fp.prone); }\n  \/* ---------- out players watch a teammate ---------- *\/\n  \/\/ When you are out in a match you see through the eyes of a teammate who is still in (never an enemy). Click \/ E \/ arrow right = next, right click \/ Q \/ arrow left = previous.\n  const wt = { on: false, pid: 0, g: null, pitch: 0, kp: false, kn: false, init: false };\n  const wvec = new THREE.Vector3(), wbanner = $('.pbf-watch');\n  function watchList() {\n    const l = [];\n    net.players.forEach(p => { if (p.pid !== net.pid && p.team === net.team && !p.coach && !p.out && p.grp && p.r.vis) l.push(p); });\n    return l.sort((a, b) => a.pid - b.pid);\n  }\n  function watchBody(g, hide) { \/\/ hide the head, jersey and name tag of the player you look through (arms, legs and marker stay)\n    if (!g) return;\n    const u = g.userData; if (u.head) u.head.visible = !hide; if (u.body) u.body.visible = !hide; if (u.lb) u.lb.visible = !hide;\n  }\n  function watchStep(d) { \/\/ d = +1 next, -1 previous\n    const l = watchList(); if (!l.length) return;\n    const i = l.findIndex(p => p.pid === wt.pid);\n    wt.pid = l[(i + d + l.length * 2) % l.length].pid;\n  }\n  const watching = () => wt.on;\n  function watchUpdate(dt) { \/\/ once per frame, after the other players have been moved\n    const m = me(), want = !!(fp.on && match.on && m && m.out && !m.coach && !net.coach);\n    let tp = null;\n    if (want) {\n      const l = watchList(), cur = l.find(p => p.pid === wt.pid);\n      if (!cur && l.length) wt.pid = l[0].pid; \/\/ the one you were watching is out too: next teammate\n      tp = l.find(p => p.pid === wt.pid) || null;\n      const k = fp.keys, np = !!(k.KeyE || k.ArrowRight), pp = !!(k.KeyQ || k.ArrowLeft);\n      if (np && !wt.kn) watchStep(1); if (pp && !wt.kp) watchStep(-1);\n      wt.kn = np; wt.kp = pp;\n      tp = l.find(p => p.pid === wt.pid) || null;\n    }\n    const g = tp ? tp.grp : null;\n    if (wt.g && wt.g !== g) watchBody(wt.g, false);\n    if (g && wt.g !== g) wt.init = false;\n    wt.g = g; wt.on = !!g;\n    gunRig.visible = !wt.on; xh.hidden = wt.on || !fp.on;\n    if (!wt.on) { if (!wbanner.hidden) wbanner.hidden = true; return; }\n    watchBody(g, true);\n    fp.firing = false;\n    g.updateMatrixWorld(true);\n    const u = g.userData, r = tp.r;\n    u.head.getWorldPosition(wvec);\n    if (!wt.init) { wt.pitch = r.pitch; wt.init = true; }\n    wt.pitch += (r.pitch - wt.pitch) * Math.min(1, dt * 14);\n    const yw = g.rotation.y;\n    camera.position.set(wvec.x - Math.sin(yw) * .1, wvec.y, wvec.z - Math.cos(yw) * .1);\n    camera.rotation.set(wt.pitch, yw, -(u.lean || 0) * .22);\n    const n = watchList().length, t = '\ud83d\udc41 Watching <b>' + esc(tp.name) + '<\/b>' + (n > 1 ? '<small>click \/ E = next teammate \u00b7 right click \/ Q = previous<\/small>' : '');\n    if (wbanner.dataset.t !== t) { wbanner.dataset.t = t; wbanner.innerHTML = t; }\n    wbanner.hidden = false;\n  }\n  function walkStep(dt) {\n    if (wt.on) { fp.firing = false; return; } \/\/ out: no walking, you only watch (the camera follows the teammate, see watchUpdate)\n    let k = fp.keys, fwd = (k.KeyW || k.ArrowUp ? 1 : 0) - (k.KeyS || k.ArrowDown ? 1 : 0),\n          str = (k.KeyD || k.ArrowRight ? 1 : 0) - (k.KeyA || k.ArrowLeft ? 1 : 0);\n    fwd = cl(fwd + tc.my + gp.my, -1, 1); str = cl(str + tc.mx + gp.mx, -1, 1); \/\/ joystick adds to the keyboard\n    if (matchLock()) { fwd = 0; str = 0; } \/\/ frozen on the start zone until the last beep\n    if (tc.lx || tc.ly) { \/\/ right stick = look\n      fp.yaw -= cv(tc.lx) * 2.8 * dt * (camera.fov \/ fovV());\n      fp.pitch = cl(fp.pitch - cv(tc.ly) * 1.9 * dt * (camera.fov \/ fovV()), -1.4, 1.4);\n    }\n    if (gp.lx || gp.ly) { \/\/ controller: right stick = look\n      fp.yaw -= cv(gp.lx) * 3.4 * dt * (camera.fov \/ fovV());\n      fp.pitch = cl(fp.pitch - cv(gp.ly) * 2.2 * dt * (camera.fov \/ fovV()), -1.4, 1.4);\n    }\n    if (fp.reloadT > 0) { fp.reloadT -= dt; if (fp.reloadT <= 0) { fp.reloadT = 0; fp.ammo[weapon] = WEAPONS[weapon].mag; } }\n    else if (fp.ammo[weapon] <= 0) startReload();\n    \/\/ jump (Space): not while crouching or frozen in a match countdown\n    fp.proneCd = Math.max(0, fp.proneCd - dt);\n    if (fp.prone && fp.jumpReq) { if (fp.proneCd <= 0) setProne(false); fp.jumpReq = false; } \/\/ jump = stand up\n    else if (k.KeyC && !fp.slide && fp.proneCd <= 0 && fp.jumpReq) { k.KeyC = false; fp.proneCd = .35; const tb = $('.tb-c'); if (tb) tb.classList.remove('on'); fp.jumpReq = false; } \/\/ crouched: jump = stand up (no jump yet, like standing up from prone)\n    else if (!matchLock() && !k.KeyC && !fp.slide && !fp.prone && fp.proneCd <= 0 && fp.jumpReq && fp.y <= 0) { fp.vy = JUMP_V; fp.y = 1e-4; }\n    fp.jumpReq = false;\n    if (fp.y > 0) { fp.vy -= GRAV * dt; fp.y += fp.vy * dt; if (fp.y <= 0) { fp.y = 0; fp.vy = 0; } }\n    const runNow = !fp.prone && !!(k.ShiftLeft || k.ShiftRight || Math.hypot(tc.mx, tc.my) > .9 || Math.hypot(gp.mx, gp.my) > .9 || gp.sprint);\n    fp.slideCd = Math.max(0, fp.slideCd - dt);\n    if (fp.crouchPress) { \/\/ crouch pressed while sprinting on the ground: slide\n      fp.crouchPress = false;\n      if ((fwd || str) && runNow && fp.y <= 0 && !fp.slide && fp.slideCd <= 0 && !matchLock()) {\n        const sy0 = Math.sin(fp.yaw), cy0 = Math.cos(fp.yaw), mx = -sy0 * fwd + cy0 * str, mz = -cy0 * fwd - sy0 * str, ml = Math.hypot(mx, mz) || 1;\n        fp.slide = { t: 0, dx: mx \/ ml, dz: mz \/ ml, v0: WEAPONS[weapon].run * 1.2 };\n      }\n    }\n    if (fp.slide) { \/\/ keeps its direction, starts fast and slows down\n      const sl = fp.slide; sl.t += dt;\n      const pr = Math.min(1, sl.t \/ SLIDE_T), sp = 1.5 + (sl.v0 - 1.5) * (1 - pr) * (1 - pr);\n      fp.x += sl.dx * sp * dt; fp.z += sl.dz * sp * dt;\n      if (pr >= 1) { fp.slide = null; fp.slideCd = .5; }\n    } else if (fwd || str) {\n      const len = Math.hypot(fwd, str),\n            sp = (fp.prone ? WEAPONS[weapon].cw * .45 : k.KeyC ? (runNow ? WEAPONS[weapon].cr : WEAPONS[weapon].cw) : (runNow ? WEAPONS[weapon].run : WEAPONS[weapon].walk)) * dt * Math.min(1, len) \/ len,\n            sy = Math.sin(fp.yaw), cy = Math.cos(fp.yaw);\n      fp.x += (-sy * fwd + cy * str) * sp;\n      fp.z += (-cy * fwd - sy * str) * sp;\n    }\n    fp.moving = !!(fwd || str) && fp.y <= 0 && !fp.slide;\n    const r = .4;\n    fp.x = cl(fp.x, -(W \/ 2 - r), W \/ 2 - r); fp.z = cl(fp.z, -(L \/ 2 - r), L \/ 2 - r);\n    \/\/ push out of bunkers (oriented box around each one)\n    bunkers.forEach(b => {\n      const m = dimOf(b), c = Math.cos(b.rot), s = Math.sin(b.rot), dx = fp.x - b.x, dz = fp.z - b.z;\n      if (fp.y >= m.h - .02) return; \/\/ jumped over a low bunker\n      const ps = polysOf(b);\n      if (Math.hypot(dx, dz) > b.cr + r) return;\n      let lx = dx * c - dz * s, lz = dx * s + dz * c;\n      for (let it = 0; it < 2; it++) ps.forEach(poly => { const u = circlePoly(poly, lx, lz, r); if (u) { lx += u[0]; lz += u[1]; } });\n      fp.x = b.x + lx * c + lz * s; fp.z = b.z - lx * s + lz * c; \/\/ true shape: you can stand inside a Staple\n    });\n    \/\/ start zones are solid, just like bunkers: you cannot walk onto them\n    [[bases.red, -1], [bases.blue, 1]].forEach(([bs, sg]) => {\n      const x0 = bs.x - bs.w \/ 2, x1 = bs.x + bs.w \/ 2, za = sg * (L \/ 2 - bs.d), zb = sg * (L \/ 2), z0 = Math.min(za, zb), z1 = Math.max(za, zb);\n      const qx = cl(fp.x, x0, x1), qz = cl(fp.z, z0, z1), dx = fp.x - qx, dz = fp.z - qz, dd = Math.hypot(dx, dz);\n      if (dd >= r) return;\n      if (dd > 1e-6) { fp.x = qx + dx \/ dd * r; fp.z = qz + dz \/ dd * r; }\n      else fp.z = za - sg * r; \/\/ centre ended up inside: step back out toward the field\n    });\n    fp.x = cl(fp.x, -(W \/ 2 - r), W \/ 2 - r); fp.z = cl(fp.z, -(L \/ 2 - r), L \/ 2 - r);\n    \/\/ peek: Q = lean left, E = lean right (not while frozen in a match countdown)\n    const tgtEye = fp.prone ? .42 : fp.slide ? WEAPONS[weapon].crouch - .15 : (k.KeyC ? WEAPONS[weapon].crouch : 1.7);\n    fp.eye += (tgtEye - fp.eye) * Math.min(1, dt * 14); \/\/ smooth up\/down\n    fp.gy += ((inStrip(fp.x, fp.z) ? (fp.z < 0 ? bases.red.h : bases.blue.h) : 0) - fp.gy) * Math.min(1, dt * 14); \/\/ step up onto a raised start zone\n    const camY = fp.eye + fp.y + fp.gy;\n    const lt = matchLock() || fp.prone ? 0 : ((k.KeyE ? 1 : 0) - (k.KeyQ ? 1 : 0));\n    fp.lean += (lt - fp.lean) * Math.min(1, dt * 10);\n    const rx = Math.cos(fp.yaw), rz = -Math.sin(fp.yaw), off = fp.lean * .5;\n    let lf = 1; while (lf > 0 && leanBlocked(fp.x + rx * off * lf, fp.z + rz * off * lf, camY)) lf -= .25; \/\/ don't peek through bunkers or the net\n    lf = Math.max(0, lf);\n    camera.position.set(fp.x + rx * off * lf, camY, fp.z + rz * off * lf);\n    camera.rotation.set(fp.pitch, fp.yaw, -fp.lean * .22);\n    if (Math.abs(camera.fov - fovV()) > .05) { camera.fov += (fovV() - camera.fov) * Math.min(1, dt * 12); camera.updateProjectionMatrix(); }\n    \/\/ hold the mouse button to keep firing\n    fp.cool -= dt;\n    if (fp.firing && fp.cool <= 0) shoot();\n  }\n\n  \/* ---------- online play (up to 5 per team, peer-to-peer via PeerJS; the host relays everything) ---------- *\/\n  const MAXP = 10, MAXC = 4, TEAMMAX = 5, TN = ['RED', 'BLUE'];\n  const net = { peer: null, conn: null, conns: new Map(), host: false, guest: false, code: '', pid: 0, team: 0, players: new Map(),\n                score: { me: 0, t: [0, 0] }, tSend: 0, fieldTimer: 0, coach: false };\n  const me = () => net.players.get(net.pid) || null;\n  const ncon = () => net.host ? net.conns.size > 0 : !!(net.conn && net.conn.open);\n  net.send = o => { \/\/ host: to everyone; guest: to the host\n    if (net.host) { o.f = 0; net.conns.forEach(c => { if (c.open) { try { c.send(o); } catch (e) {} } }); }\n    else if (net.conn && net.conn.open) { try { net.conn.send(o); } catch (e) {} }\n  };\n  const kickSlide = pid => { const q = net.players.get(pid); if (q && q.grp) q.grp.userData.slideK = 1; };\n  const relay = (o, skip) => net.conns.forEach(c => { if (c !== skip && c.open) { try { c.send(o); } catch (e) {} } });\n  const nstat = h => { $('.n-status').innerHTML = h; sdTab.play = 'net'; if (mode === 'play') sideRender(); };\n  const sendField = () => net.send({ t: 'field', s: serialize() });\n  const nums = a => a.slice(0, 3).map(v => isFinite(+v) ? +v : 0);\n  const myName = () => ($('.n-name').value || '').replace(\/[<>&\"]\/g, '').trim().slice(0, 12);\n  const hostStatus = () => nstat('Room code: <b style=\"font-size:20px;letter-spacing:.12em\">' + net.code + '<\/b><br>' + Array.from(net.players.values()).filter(p => !p.coach).length + '\/' + MAXP + ' players connected' + (Array.from(net.players.values()).some(p => p.coach) ? ' + ' + Array.from(net.players.values()).filter(p => p.coach).length + ' spectator' : '') + ' (max ' + TEAMMAX + ' per team). Share the code with your team and the other team.');\n\n  function badge() {\n    const b = $('.pbf-net'); b.hidden = !ncon(); if (b.hidden) return;\n    const t = net.team, al = [0, 0]; net.players.forEach(p => { if (!p.out) al[p.team]++; });\n    let h = '<b style=\"color:' + (t ? '#7fb0ff' : '#ff8e91') + '\">' + TN[t] + '<\/b> \u00b7 ' + net.score.me + ' hits';\n    if (match.on || net.score.t[0] || net.score.t[1]) h += ' &nbsp;|&nbsp; <span style=\"color:#ff8e91\">Red ' + net.score.t[0] + '<\/span> \u2013 <span style=\"color:#7fb0ff\">' + net.score.t[1] + ' Blue<\/span>';\n    if (match.on) h += ' &nbsp;|&nbsp; alive ' + al[0] + ' v ' + al[1];\n    if (match.on && isFlag()) h += ' &nbsp;|&nbsp; R' + fl.rn + '\/' + FLAG_ROUNDS + (fl.bonus ? ' \u2b50 bonus' : ' T' + fl.tn + '\/' + FLAG_TRIES) + ' \u00b7 ' + TN[fl.att] + ' attacks \u00b7 ' + mmss(fl.tt) + (fl.bz >= 0 ? ' \u00b7 \ud83d\udd14 ' + Math.ceil(fl.bz) : '');\n    b.innerHTML = h;\n  }\n  function renderRoster() {\n    const box = $('.n-roster');\n    if (!net.players.size) { box.innerHTML = ''; return; }\n    const cols = [[], []], cn = [0, 0], cc = [];\n    Array.from(net.players.values()).sort((a, b) => a.pid - b.pid).forEach(p => {\n      const nm = esc(p.name) + (p.pid === net.pid ? ' (you)' : '') + (p.ping > 0 ? ' <span style=\"font-size:11px;color:' + (p.ping < 80 ? '#2ecc71' : p.ping < 160 ? '#f2b632' : '#e5484d') + '\">' + p.ping + ' ms<\/span>' : '');\n      if (p.coach) cc.push(nm); else { cn[p.team]++; cols[p.team].push(nm); }\n    });\n    box.innerHTML = [0, 1].map(t => '<div><b style=\"color:' + (t ? '#7fb0ff' : '#ff8e91') + '\">' + TN[t] + ' ' + cn[t] + '\/' + TEAMMAX + '<\/b>' + (cols[t].join('<br>') || '<span style=\"color:var(--mut)\">\u2013<\/span>') + '<\/div>').join('') + (cc.length ? '<div style=\"grid-column:1\/-1\"><b>\ud83d\udc41 Spectators (no team)<\/b>' + cc.join('<br>') + '<\/div>' : '');\n  }\n\n  \/\/ every other player: a speedsoft player in team colors (jersey, pants, harness + pods, knee pads, full-face mask, marker)\n  function makeSpeedRifle(wc) { \/\/ the speedsoft rifle as a group: round silver upper (also behind the carbon barrel), silver lower frame with a truss window, tank grip. One model for first person and for what other players hold\n    wc = wc || WC_DEF;\n    const G = new THREE.Group();\n    if (!makeSpeedRifle.carbon) {\n      const cv = document.createElement('canvas'); cv.width = cv.height = 32; const g = cv.getContext('2d');\n      for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) { g.fillStyle = (x + y) % 2 ? '#111419' : '#1f242b'; g.fillRect(x * 8, y * 8, 8, 8); }\n      g.strokeStyle = 'rgba(95,125,175,.30)'; g.lineWidth = 1.2; for (let i = -32; i < 64; i += 8) { g.beginPath(); g.moveTo(i, 0); g.lineTo(i + 32, 32); g.stroke(); }\n      const t = new THREE.CanvasTexture(cv); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(6, 8); makeSpeedRifle.carbon = t;\n    }\n    const M = (c, o) => new THREE.MeshStandardMaterial(Object.assign({ color: c, roughness: .45, metalness: .35 }, o || {}));\n    const silver = M(wc.rb, { roughness: .5, metalness: .3 }), silverD = M(shade(wc.rb, 1.6), { roughness: .45, metalness: .4 }), logo = M(0x6b7078), dark = M(wc.rg, { roughness: .55, metalness: .3 }), mid = M(shade(wc.rg, 1.85), { roughness: .55, metalness: .3 });\n    const mag = M(wc.rm, { roughness: .6, metalness: .2 }), magL = M(shade(wc.rm, 3.4), { roughness: .55, metalness: .3 }), bronze = M(wc.ra, { roughness: .35, metalness: .7 }), red = M(wc.rt, { roughness: .35, metalness: .5 });\n    const carbon = new THREE.MeshStandardMaterial({ map: makeSpeedRifle.carbon, roughness: .3, metalness: .5 });\n    const add = (geo, m, x, y, z) => { const k = new THREE.Mesh(geo, m); k.position.set(x, y, z); k.castShadow = true; G.add(k); return k; };\n    const box = (w, h, d, m, x, y, z, rx) => { const k = add(new THREE.BoxGeometry(w, h, d), m, x, y, z); if (rx) k.rotation.x = rx; return k; };\n    const cyl = (r, len, m, x, y, z) => { const k = add(new THREE.CylinderGeometry(r, r, len, 22), m, x, y, z); k.rotation.x = Math.PI \/ 2; return k; }; \/\/ lying along the shooting direction\n    const AY = .008; \/\/ height of the barrel axis (the shots leave from here)\n    \/\/ barrel and round upper receiver\n    cyl(.0285, .36, carbon, 0, AY, -.14);           \/\/ black carbon tube: from the muzzle (z -.32) back over the receiver, almost to the rear block\n    cyl(.0305, .012, silverD, 0, AY, -.314);        \/\/ muzzle ring\n    box(.005, .014, .008, silverD, 0, .043, -.30);  \/\/ front sight\n    cyl(.034, .125, silver, 0, AY, .1025);          \/\/ round silver upper: only the short rear part behind the carbon tube\n    cyl(.0355, .008, silverD, 0, AY, .044);         \/\/ lip where the carbon tube goes in\n    [.075, .095, .115].forEach(z => cyl(.0348, .004, silverD, 0, AY, z)); \/\/ grooves on the rear of the upper\n    { const cap = add(new THREE.SphereGeometry(.034, 24, 14), silver, 0, AY, .165); cap.scale.set(1, 1, .62); } \/\/ rounded rear end\n    add(new THREE.CylinderGeometry(.0275, .0275, .1, 22), silver, 0, -.04, .147); \/\/ round rear column: upper receiver -> grip\n    \/\/ lower frame: two silver side plates with a truss window and the trigger guard, a dark inside, red trigger\n    if (!makeSpeedRifle.plate) {\n      const ps = new THREE.Shape(); [[-.15, -.02], [.12, -.02], [.12, -.12], [0, -.12], [-.03, -.095], [-.15, -.0646]].forEach((q, i) => i ? ps.lineTo(q[0], q[1]) : ps.moveTo(q[0], q[1])); ps.closePath();\n      const hole = pts => new THREE.Path(pts.map(q => new THREE.Vector2(q[0], q[1])));\n      ps.holes.push(hole([[-.14, -.034], [-.095, -.034], [-.095, -.07], [-.14, -.052]])); \/\/ truss window in front of the trigger\n      ps.holes.push(hole([[.005, -.05], [.105, -.05], [.105, -.105], [.02, -.105]]));        \/\/ trigger guard opening\n      const geo = new THREE.ExtrudeGeometry(ps, { depth: .006, bevelEnabled: false }); geo.rotateY(-Math.PI \/ 2); geo.translate(.003, 0, 0); \/\/ profile in the z-y plane, 6 mm thick\n      makeSpeedRifle.plate = geo;\n    }\n    [-1, 1].forEach(sx => {\n      add(makeSpeedRifle.plate, silver, sx * .0205, 0, 0);\n      box(.006, .005, Math.hypot(.038, .014), silver, sx * .0205, -.04355, -.119, -Math.atan2(.014, .038)); \/\/ strut across the window\n      box(.0016, .01, .034, logo, sx * .0238, -.055, -.035);                                            \/\/ slot with the little logo\n    });\n    box(.032, .012, .26, silver, 0, -.026, -.02);    \/\/ bridge between the plates, under the receiver\n    box(.026, .044, .055, M(0x050607), 0, -.055, -.1175);    \/\/ dark inside, seen through the window\n    box(.005, .032, .008, red, 0, -.078, .05, .35); \/\/ red trigger\n    \/\/ black magazine, pushed up into the lower frame in front of the trigger guard (AR style: the bottom curves a little forward)\n    box(.052, .012, .07, mag, 0, -.1, -.05);       \/\/ magazine well rim under the frame (wider than the frame)\n    box(.046, .08, .06, mag, 0, -.1375, -.05, .1);  \/\/ the magazine itself: wide, and further to the front\n    box(.048, .01, .064, magL, 0, -.176, -.054, .1); \/\/ floor plate\n    [-.15, -.135, -.12].forEach(y => box(.0476, .0016, .052, magL, 0, y, -.05 + (y + .1375) * .1, .1)); \/\/ ribs along the magazine\n    \/\/ tank grip: grip, bottle holder, valve, air tank like a stock\n    box(.042, .09, .068, dark, 0, -.13, .15);       \/\/ big grip\n    cyl(.026, .066, dark, 0, -.185, .15);           \/\/ air bottle holder at the bottom of the grip\n    cyl(.016, .05, mid, 0, -.185, .2);              \/\/ valve \/ regulator\n    cyl(.042, .17, dark, 0, -.185, .31);            \/\/ air tank: oval bottle pointing back like a stock\n    cyl(.0435, .03, bronze, 0, -.185, .27);         \/\/ band round the tank\n    { const dome = add(new THREE.SphereGeometry(.042, 14, 10), dark, 0, -.185, .395); dome.scale.z = .9; } \/\/ rounded end of the tank\n    return G;\n  }\n  const _la = new THREE.Vector3(), _ly = new THREE.Vector3(0, 1, 0), _aS = new THREE.Vector3(), _aH = new THREE.Vector3(), _aE = new THREE.Vector3(), _aD = new THREE.Vector3(), _aP = new THREE.Vector3();\n  function limb(m, a, b) { \/\/ stretch a unit-length cylinder between two points\n    _la.subVectors(b, a); const l = _la.length() || 1e-4;\n    m.position.set((a.x + b.x) \/ 2, (a.y + b.y) \/ 2, (a.z + b.z) \/ 2); m.scale.y = l; m.quaternion.setFromUnitVectors(_ly, _la.divideScalar(l));\n  }\n  function arm2(up, fo, elb, S, H, side) { \/\/ two-bone arm: upper arm + forearm reach from the shoulder to the hand, the elbow hangs down and a bit outward\n    const A = .3, B = .3;\n    _aD.subVectors(H, S); const d = _aD.length() || 1e-4, dc = Math.min(d, A + B - .005); _aD.divideScalar(d);\n    const x = (A * A - B * B + dc * dc) \/ (2 * dc), h = Math.sqrt(Math.max(0, A * A - x * x));\n    _aP.set(side * .08, -1, 0); _aP.addScaledVector(_aD, -_aP.dot(_aD)); if (_aP.lengthSq() < 1e-6) _aP.set(0, 0, 1); _aP.normalize();\n    _aE.copy(S).addScaledVector(_aD, x).addScaledVector(_aP, h);\n    limb(up, S, _aE); limb(fo, _aE, H); elb.position.copy(_aE);\n  }\n  function buildAvatar(p) {\n    if (p.grp) { scene.remove(p.grp); disposeGroup(p.grp); }\n    const m = (c, o) => new THREE.MeshStandardMaterial(Object.assign({ color: c, roughness: .6 }, o || {}));\n    const team = PAINT[p.team], cast = o => { o.castShadow = true; return o; };\n    \/\/ legs stay put (and swing when walking); the upper body (torso + head + arms + marker) pivots at the waist (y = .6) when peeking\n    const legs = new THREE.Group(); legs.position.y = .3; \/\/ origin in the middle of the legs, hips at +.3\n    const legP = [-1, 1].map(sx => { \/\/ hip -> thigh -> knee -> shin -> boot\n      const hip = new THREE.Group(); hip.position.set(sx * .075, .3, 0);\n      const thigh = cast(new THREE.Mesh(new THREE.CylinderGeometry(.07, .06, .3, 10), m(0x2a2f38))); thigh.position.y = -.15;\n      const knee = new THREE.Group(); knee.position.y = -.3;\n      const shin = cast(new THREE.Mesh(new THREE.CylinderGeometry(.06, .05, .3, 10), m(0x2a2f38))); shin.position.y = -.15;\n      const pad = cast(new THREE.Mesh(new THREE.BoxGeometry(.1, .09, .05), m(0x14171b))); pad.position.set(0, 0, -.07);\n      const boot = cast(new THREE.Mesh(new THREE.BoxGeometry(.1, .07, .24), m(0x14171b))); boot.position.set(0, -.28, -.05);\n      knee.add(shin, pad, boot); hip.add(thigh, knee); legs.add(hip); hip.userData.knee = knee; return hip;\n    });\n    const body = cast(new THREE.Mesh(new THREE.CylinderGeometry(.14, .15, .6, 14), m(team))); body.position.y = .3; \/\/ jersey\n    const head = new THREE.Group(); head.position.y = .85; \/\/ head with a paintball mask (the group centre is where the eyes are); facing -z\n    { \/\/ paintball mask: goggle with a mirrored lens, sloped visor, vented lower face guard, side outriggers with screws and a strap round the back\n      const TAU = Math.PI * 2, DS = THREE.DoubleSide;\n      const sp = (v, e) => Math.sign(v) * Math.pow(Math.abs(v), e);\n      const mat = (c, o) => { const k = m(c, o); k.side = DS; return k; };\n      const frameM = mat(0x0b0c0e, { roughness: .25, metalness: .3 }), lensM = mat(0x56626d, { roughness: .2, metalness: .7 });\n      const guardM = mat(0x2a302b, { roughness: .5 }), slotM = mat(0x040506, { roughness: 1 }), strapM = mat(0x3a3f46, { roughness: .85 }), visM = mat(0x16181b, { roughness: .6 });\n      const mk = (geo, ma, x, y, z) => { const k = new THREE.Mesh(geo, ma); k.position.set(x || 0, y || 0, z || 0); head.add(cast(k)); return k; };\n      const surf = (rows, closed) => { \/\/ a surface through rings of [x, y, z] points\n        const pos = [], idx = [], n = rows[0].length;\n        rows.forEach(r => r.forEach(q => pos.push(q[0], q[1], q[2])));\n        for (let i = 0; i < rows.length - 1; i++) for (let j = 0; j < n - (closed ? 0 : 1); j++) { const j2 = (j + 1) % n, a = i * n + j, b = i * n + j2, c = (i + 1) * n + j, d = (i + 1) * n + j2; idx.push(a, c, b, b, c, d); }\n        const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); g.computeVertexNormals(); return g;\n      };\n      const ring = (a, b, z, y0, k, N) => { const r = []; for (let i = 0; i < N; i++) { const t = i \/ N * TAU; r.push([a * k * sp(Math.cos(t), .72), y0 + b * k * sp(Math.sin(t), .72), z]); } return r; }; \/\/ rounded-rectangle outline\n      mk(new THREE.SphereGeometry(.112, 16, 12), m(0x1c1f24), 0, .005, .012);                        \/\/ head under the mask\n      \/\/ goggle: black frame (a rounded box that sticks out in front of the face) and the mirrored lens, slightly curved\n      const GA = .126, GB = .054, GY = .03;\n      mk(surf([ring(GA + .006, GB + .006, -.055, GY, .88, 32), ring(GA + .006, GB + .006, -.14, GY, 1, 32), ring(GA - .008, GB - .008, -.14, GY, 1, 32)], true), frameM);\n      mk(surf([1, .8, .6, .4, .2, 0].map(k => ring(GA - .008, GB - .008, -.131 - .011 * (1 - k * k), GY, k, 32)), true), lensM);\n      mk(new THREE.CylinderGeometry(.03, .037, .08, 14), guardM, 0, -.035, -.118).scale.set(1, 1, .62);    \/\/ nose bridge\n      \/\/ visor: sloped plate over the goggle, with the team colour on top\n      { const pts = [[-.125, -.05]]; for (let i = 0; i <= 10; i++) { const x = -.14 + i * .028; pts.push([x, -.148 - .022 * (1 - Math.pow(x \/ .14, 2))]); } pts.push([.125, -.05]);\n        const yt = (z, x) => .096 - (z + .05) * .1 - .026 * Math.pow(x \/ .14, 2), top = pts.map(q => [q[0], yt(q[1], q[0]), q[1]]), bot = pts.map(q => [q[0], yt(q[1], q[0]) - .018, q[1]]); \/\/ arched: the sides come down like a peak\n        const cap = (r, dy) => { const c = [0, r.reduce((t, q) => t + q[1], 0) \/ r.length + dy, r.reduce((t, q) => t + q[2], 0) \/ r.length], pos = [c[0], c[1], c[2]], idx = []; r.forEach(q => pos.push(q[0], q[1] + dy, q[2])); for (let i = 0; i < r.length; i++) idx.push(0, 1 + i, 1 + (i + 1) % r.length); const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); g.computeVertexNormals(); return g; };\n        mk(surf([top, bot], true), visM); mk(cap(top, 0), visM); mk(cap(bot, 0), visM); }\n      mk(new THREE.BoxGeometry(.15, .003, .075), m(team, { roughness: .5 }), 0, .1015, -.11).rotation.x = .1;\n      \/\/ lower face guard: wraps the jaw from the nose to the ears, narrower towards the chin, with rows of vent slots\n      const prof = y => { const t = Math.max(0, Math.min(1, (-.02 - y) \/ .135)); return [.127 - .056 * Math.pow(t, 1.4), .13 - .01 * t, -.012]; }; \/\/ half width, depth, centre z\n      const P = (y, th) => { const q = prof(y); return [q[0] * sp(Math.sin(th), .74), y, q[2] - q[1] * sp(Math.cos(th), .74)]; };\n      const TH = 1.85, NT = 26, ys = [-.02, -.045, -.07, -.095, -.12, -.155];\n      mk(surf(ys.map(y => { const r = []; for (let i = 0; i <= NT; i++) r.push(P(y, -TH + 2 * TH * i \/ NT)); return r; }), false), guardM);\n      { const pos = [], idx = []; \/\/ vent slots: little dark quads that follow the shield\n        [-.052, -.08, -.108, -.135].forEach(yc => { for (let c = -9; c <= 9; c += 2) { const th = c * .155, dh = .045, hh = .0105, v0 = pos.length \/ 3;\n          [[yc + hh, th - dh], [yc + hh, th + dh], [yc - hh, th - dh], [yc - hh, th + dh]].forEach(([y, t]) => { const q = P(y, t), q2 = prof(y), nx = Math.sin(t), nz = -Math.cos(t); pos.push(q[0] + nx * .0012, q[1], q[2] + nz * .0012); });\n          idx.push(v0, v0 + 2, v0 + 1, v0 + 1, v0 + 2, v0 + 3); } });\n        const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); g.computeVertexNormals(); mk(g, slotM); }\n      [-1, 1].forEach(sx => {\n        mk(new THREE.BoxGeometry(.012, .04, .09), frameM, sx * .124, .03, -.02);                                       \/\/ outrigger from the goggle back to the strap\n        mk(new THREE.CylinderGeometry(.012, .012, .008, 12), frameM, sx * .125, -.04, -.012).rotation.z = Math.PI \/ 2; \/\/ pivot screw\n      });\n      mk(new THREE.CylinderGeometry(.119, .119, .055, 24, 1, true), strapM, 0, .032, .006).scale.set(1.05, 1, 1);        \/\/ strap round the back of the head\n      mk(new THREE.CylinderGeometry(.1195, .1195, .02, 24, 1, true), mat(0x23272c, { roughness: .9 }), 0, .032, .006).scale.set(1.05, 1, 1);\n    }\n    const piv = new THREE.Group(); piv.position.set(.18, .65, 0);\n    const g = cast(new THREE.Mesh(new THREE.BoxGeometry(.06, .08, .55), m(0x23272e))); g.position.z = -.3; piv.add(g);\n    const pist = new THREE.Group(); pist.position.set(-.065, .025, -.3); pist.visible = false; piv.add(pist); const pslide = makeHiCapa(pist, wcOf(p)); \/\/ hi-capa, same model and place as in first person\n    const rifle = makeSpeedRifle(wcOf(p)); rifle.position.z = -.5; rifle.visible = false; piv.add(rifle); \/\/ same place as in first person: muzzle ~1.14 m ahead of the eyes\n    const armM = () => cast(new THREE.Mesh(new THREE.CylinderGeometry(.058, .05, 1, 8), m(team)));\n    const ball = (r, c) => cast(new THREE.Mesh(new THREE.SphereGeometry(r, 8, 6), m(c)));\n    const A = [0, 1].map(() => ({ up: armM(), fo: armM(), el: ball(.056, team), hand: ball(.052, 0x14171b) })); \/\/ [0] marker arm, [1] support arm\n    const lb = makeLabel(p.name); lb.material.depthTest = true; lb.scale.set(1.6, .4, 1); lb.position.y = 2.2;\n    const inner = new THREE.Group(); inner.position.y = .6; inner.add(body, head, piv);\n    A.forEach(a => inner.add(a.up, a.fo, a.el, a.hand));\n    const fig = new THREE.Group(); fig.add(legs, inner); fig.scale.setScalar(1.12); \/\/ 1.8 m tall: the head is at eye height, so the marker lines up with the shots \/\/ the whole body: tips over to lie flat when prone\n    const grp = new THREE.Group(); grp.add(fig, lb); grp.visible = false;\n    Object.assign(grp.userData, { fig, pr: 0, piv, inner, lb, legs, legP, A, rifle, pist, pslide, slideK: 0, hp: [[-.04, -.1], [-.04, -.4]], ph: 0, amp: 0, body, head, cs: 0, lean: 0, gmesh: g, wpn: '' });\n    scene.add(grp); p.grp = grp;\n  }\n  function addPlayer(pid, team, name, coach, wc) {\n    const p = { pid, team, name, coach: !!coach, out: !!coach, grp: null, wc: wc || null, r: { vis: false, x: 0, z: 0, yaw: 0, pitch: 0, crouch: false, lean: 0, wpn: 'm4', hand: 1, y: 0 } };\n    net.players.set(pid, p);\n    if (pid !== net.pid && !coach) buildAvatar(p); \/\/ coaches have no figure on the field\n    return p;\n  }\n  function removePlayer(pid) {\n    const p = net.players.get(pid);\n    if (p && p.grp) { scene.remove(p.grp); disposeGroup(p.grp); }\n    net.players.delete(pid);\n  }\n  function setR(p, x, z, yaw, pitch, c, l, g, v, h, y) {\n    const r = p.r; x = +x || 0; z = +z || 0; yaw = +yaw || 0;\n    if (!r.vis && v && p.grp) { p.grp.position.set(x, 0, z); p.grp.rotation.y = yaw; }\n    r.vis = !!v; r.x = x; r.z = z; r.yaw = yaw; r.pitch = +pitch || 0; r.crouch = !!c; r.prone = +c === 2; r.lean = cl(+l || 0, -1, 1); r.wpn = WLIST[g | 0] || 'm4'; r.hand = +h < 0 ? -1 : 1; r.y = cl(+y || 0, 0, 3);\n  }\n  function hitsPlayer(p, o, s) { \/\/ does the ball's path this frame cross that player?\n    const gp = p.grp, ln = gp.userData.lean || 0, yw = gp.rotation.y, lo = ln * .15; \/\/ hitbox follows a peeking player (upper body only)\n    const ax = gp.position.x + Math.cos(yw) * lo, az = gp.position.z - Math.sin(yw) * lo, l2 = s.x * s.x + s.z * s.z || 1e-9;\n    const gy = gp.position.y || 0;\n    if (p.r.prone) { \/\/ lying down: the whole body is a long thin target, head forward and feet trailing behind\n      const bx = Math.sin(yw) * 1.7, bz = Math.cos(yw) * 1.7, bl = bx * bx + bz * bz;\n      for (let i = 0; i <= 10; i++) {\n        const qx = o.x + s.x * i \/ 10, qz = o.z + s.z * i \/ 10, qy = o.y + s.y * i \/ 10;\n        const u = cl(((qx - ax) * bx + (qz - az) * bz) \/ bl, 0, 1);\n        const top = u < .5 ? .44 : .34, rad = u < .5 ? .19 : .14; \/\/ head and torso a bit wider and higher than the legs (the model is ~0.4 m high lying down)\n        if (qy > gy + .02 && qy < gy + top && Math.hypot(qx - (ax + bx * u), qz - (az + bz * u)) < rad) return true;\n      }\n    } else {\n      const t = cl(((ax - o.x) * s.x + (az - o.z) * s.z) \/ l2, 0, 1), py = o.y + s.y * t;\n      if (Math.hypot(o.x + s.x * t - ax, o.z + s.z * t - az) < .24 + Math.abs(ln) * .12 && py > gy && py < gy + (p.r.crouch ? WEAPONS[p.r.wpn].crouchH : 1.8)) return true;\n    }\n    \/\/ the marker counts too: a ball that hits the gun is a hit (same place as the model: 0.17 m under the eyes, a bit to the side, pointing where the player looks; lying down it points out ahead of the head along the ground)\n    const K = 1.12, GL = { m4: [.25, 1.04, .09], pistol: [.2, .47, .05] }[p.r.wpn] || [.25, 1.04, .09];\n    const pr = p.r.prone, eyeH = p.r.crouch && !pr ? WEAPONS[p.r.wpn].crouch : 1.7, pc = Math.cos(p.r.pitch || 0), ps = Math.sin(p.r.pitch || 0);\n    const L0 = pr ? 0 : GL[0] * K, L1 = pr ? .88 : GL[1] * K;\n    const fx = -Math.sin(yw), fz = -Math.cos(yw), rx = Math.cos(yw) * .2 * (p.r.hand || 1), rz = -Math.sin(yw) * .2 * (p.r.hand || 1), gyy = pr ? gy + .27 : gy + eyeH - .17;\n    const P0 = [gp.position.x + fx * pc * L0 + rx, gyy + ps * L0, gp.position.z + fz * pc * L0 + rz];\n    const D = [fx * pc * (L1 - L0), ps * (L1 - L0), fz * pc * (L1 - L0)], dl = D[0] * D[0] + D[1] * D[1] + D[2] * D[2] || 1e-9;\n    for (let i = 0; i <= 12; i++) { \/\/ sample the ball's path and measure the distance to the gun's axis\n      const qx = o.x + s.x * i \/ 12, qy = o.y + s.y * i \/ 12, qz = o.z + s.z * i \/ 12;\n      const u = cl(((qx - P0[0]) * D[0] + (qy - P0[1]) * D[1] + (qz - P0[2]) * D[2]) \/ dl, 0, 1);\n      if (Math.hypot(qx - P0[0] - D[0] * u, qy - P0[1] - D[1] * u, qz - P0[2] - D[2] * u) < GL[2]) return true;\n    }\n    return false;\n  }\n  function enemyHit(o, s) { \/\/ first player of the other team that the ball crosses\n    let hit = null;\n    net.players.forEach(p => { if (!hit && p.pid !== net.pid && p.team !== net.team && p.grp && p.grp.visible && !(match.on && p.out) && hitsPlayer(p, o, s)) hit = p; });\n    return hit;\n  }\n  function onHit(f, v) { \/\/ shooter f hit player v; returns false when the hit does not count\n    const vp = net.players.get(v), sp = net.players.get(f);\n    if (!vp || !sp || vp.team === sp.team) return false;\n    if (match.on) { if (match.phase !== 'live' || vp.out || sp.out) return false; vp.out = true; }\n    if (v === net.pid) {\n      fp.firing = false;\n      rumble(250, .9, .5); const el = $('.pbf-hit'); el.classList.add('on'); setTimeout(() => el.classList.remove('on'), 90);\n      if (match.on) showCount(\"\ud83d\udca5 You're out!\", true, '#ff8e91');\n    }\n    if (f === net.pid) { net.score.me++; if (!match.on && !quiz.on) { setHud('\ud83c\udfaf <b>Hit!<\/b>'); setTimeout(hint, 900); } }\n    if (!net.guest) { stat(f).k++; stat(v).d++; } \/\/ the host counts for everybody\n    badge();\n    if (net.host) checkRoundEnd();\n    return true;\n  }\n  function myHit(v) { if (onHit(net.pid, v)) net.send({ t: 'h', v }); }\n  function checkRoundEnd() { \/\/ host decides when a round is over\n    if (!net.host || !match.on || match.phase !== 'live') return;\n    if (isFlag()) return; \/\/ Flag mode: flagCheck() decides when a try is over\n    const al = [0, 0]; net.players.forEach(p => { if (!p.out) al[p.team]++; });\n    if (al[0] && al[1]) return;\n    const w = al[0] ? 0 : al[1] ? 1 : -1;\n    net.send({ t: 'rend', w }); roundEnd(w);\n  }\n\n  \/\/ teams\n  function hostSetTeam(pid, t) {\n    const p = net.players.get(pid); if (!p || (!matchOpen() && !p.coach) || (match.on && isFlag() && !p.coach)) return false; \/\/ no team changes during a Flag match\n    let cnt = 0; net.players.forEach(q => { if (q !== p && q.team === t && !q.coach) cnt++; });\n    if (!p.coach && cnt >= TEAMMAX) return false;\n    if (p.team !== t) { p.team = t; p.slot = 0; p.sw = Date.now(); if (pid === net.pid) net.team = t; if (p.grp) buildAvatar(p); }\n    return true;\n  }\n  function hostBalance() { \/\/ host: move players so the teams differ by at most one; the most recent team switcher moves first. Returns how many were moved.\n    if (!net.host) return 0;\n    const moved = [];\n    for (let g = 0; g < 10; g++) {\n      const T = [[], []]; net.players.forEach(p => { if (!p.coach) T[p.team].push(p); });\n      if (Math.abs(T[0].length - T[1].length) <= 1) break;\n      const big = T[0].length > T[1].length ? 0 : 1;\n      const p = T[big].sort((a, b) => (b.sw || 0) - (a.sw || 0) || b.pid - a.pid)[0];\n      p.team = 1 - big; p.sw = 0; p.slot = 0;\n      if (p.pid === net.pid) { net.team = p.team; $('.n-team').value = p.team; }\n      if (p.grp) buildAvatar(p);\n      moved.push(p);\n    }\n    if (moved.length) {\n      const m = '\u2696 Teams balanced: ' + moved.map(p => p.name + ' \u2192 ' + TN[p.team]).join(', ');\n      sendRoster(); renderRoster(); badge(); updateLabels(); net.send({ t: 'bal', m }); setHud(esc(m)); setTimeout(hint, 4000);\n    }\n    return moved.length;\n  }\n  function replaceAll() { \/\/ the roster changed during the \"get ready\" pause: everybody goes back to their (new) start spot\n    if (!net.host || !match.on || match.phase !== 'count') return;\n    if (isFlag()) flagReset();\n    net.send({ t: 'place' });\n    if (fp.on && !net.coach) placeAtStart();\n  }\n  function hostSetRole(pid, co) { \/\/ coach <-> player, only between matches; a coach belongs to no team\n    const p = net.players.get(pid); if (!p || !matchOpen()) return false;\n    if (p.coach === co) return true;\n    const pl = Array.from(net.players.values()).filter(q => !q.coach);\n    if (co) { if (Array.from(net.players.values()).filter(q => q.coach).length >= MAXC) return false; }\n    else {\n      if (pl.length >= MAXP) return false;\n      if (pl.filter(q => q.team === p.team).length >= TEAMMAX) { \/\/ team full: try the other one\n        if (pl.filter(q => q.team !== p.team).length >= TEAMMAX) return false;\n        p.team = 1 - p.team; if (pid === net.pid) { net.team = p.team; $('.n-team').value = p.team; }\n      }\n    }\n    p.coach = co; p.out = co || (match.on && isFlag() && fl.bonus);\n    if (co) { if (p.grp) { scene.remove(p.grp); disposeGroup(p.grp); p.grp = null; } p.r.vis = false; }\n    else if (pid !== net.pid) buildAvatar(p);\n    if (pid === net.pid) { net.coach = co; coachUI(co); if (!co) hint(); }\n    return true;\n  }\n  function sendRoster() { pubKick(); net.send({ t: 'roster', l: Array.from(net.players.values()).map(p => ({ i: p.pid, t: p.team, n: p.name, c: p.coach ? 1 : 0, s: p.slot | 0, w: wcArr(p.pid === net.pid ? wcol : wcOf(p)) })) }); }\n  function syncRoster(list) { \/\/ guests: follow the host's player list\n    const ids = new Set(list.map(e => e.i)), slot0 = mySlot();\n    Array.from(net.players.keys()).forEach(id => { if (!ids.has(id)) removePlayer(id); });\n    list.forEach(e => {\n      let p = net.players.get(e.i);\n      if (!p) p = addPlayer(e.i, e.t, String(e.n || '').slice(0, 12), !!e.c, e.i === net.pid ? null : parseWc(e.w));\n      else {\n        const ch = p.team !== e.t; p.team = e.t; p.name = String(e.n || '').slice(0, 12); if (ch && p.grp) buildAvatar(p);\n        if (!!e.c !== p.coach) { p.coach = !!e.c; p.out = p.coach; if (p.coach) { if (p.grp) { scene.remove(p.grp); disposeGroup(p.grp); p.grp = null; } p.r.vis = false; } else if (p.pid !== net.pid) buildAvatar(p); }\n      }\n      const q = net.players.get(e.i); if (q) q.slot = cl(e.s | 0, 0, 5);\n      if (q && e.i !== net.pid && Array.isArray(e.w)) { const nw = parseWc(e.w); if (WCK.some(k => nw[k] !== wcOf(q)[k])) { q.wc = nw; if (q.grp) buildAvatar(q); } } \/\/ somebody changed their weapon colours\n      if (e.i === net.pid) { net.team = e.t; if (!!e.c !== !!net.coach) { net.coach = !!e.c; $('.n-coach').checked = net.coach; if (net.coach && fp.on) exitWalk(true); coachUI(net.coach); if (!net.coach) hint(); } } \/\/ the host's list decides my role\n    });\n    $('.n-team').value = net.team; renderRoster(); badge(); updateLabels();\n    if (mySlot() !== slot0 && match.on && match.phase !== 'live' && fp.on && !net.coach) placeAtStart(); \/\/ your spot changed: stand on it\n  }\n  $('.n-team').onchange = e => {\n    const t = +e.target.value ? 1 : 0;\n    if (net.host) {\n      if (!hostSetTeam(net.pid, t)) nstat('You cannot switch now (the team is full, or a round is running).');\n      e.target.value = net.team; sendRoster(); replaceAll(); renderRoster(); badge(); updateLabels(); markVis();\n    } else if (net.guest && ncon()) net.send({ t: 'team', v: t });\n    else net.team = t;\n  };\n\n  \/\/ host side: one connection per guest\n  function hostAccept(c) {\n    c.on('data', m => hostMsg(c, m));\n    c.on('close', () => hostDrop(c));\n    c.on('error', () => {});\n  }\n  function hostMsg(c, m) {\n    if (!net.host || !m || typeof m !== 'object') return;\n    if (!c.pid) {\n      if (m.t !== 'join') return;\n      const co = !!m.c, ctm = m.tm ? 1 : 0, all = Array.from(net.players.values());\n      if (co ? all.filter(q => q.coach).length >= MAXC : all.filter(q => !q.coach).length >= MAXP) { try { c.send({ t: 'full' }); } catch (e) {} setTimeout(() => { try { c.close(); } catch (e) {} }, 400); return; }\n      let pid = 1; while (net.players.has(pid)) pid++;\n      const cnt = [0, 0]; net.players.forEach(p => { if (!p.coach) cnt[p.team]++; });\n      const team = co ? ctm : (cnt[0] <= cnt[1] ? 0 : 1); \/\/ a coach picks the team, players are balanced\n      c.pid = pid; net.conns.set(pid, c);\n      const np = addPlayer(pid, team, String(m.n || '').replace(\/[<>&\"]\/g, '').trim().slice(0, 12) || 'Player ' + (pid + 1), co, parseWc(m.w));\n      if (match.on) np.out = true; \/\/ joined mid-match: waits for the next round\n      try {\n        c.send({ t: 'hello', pid, team, co: co ? 1 : 0 });\n        c.send({ t: 'field', s: serialize() });\n        c.send(marksMsg(team, co));\n        c.send({ t: 'mode', m: $('.n-mode').value });\n        if (match.on) c.send({ t: 'late' });\n      } catch (e) {}\n      sendRoster(); renderRoster(); badge(); updateMatchUI(); hostStatus();\n      return;\n    }\n    const p = net.players.get(c.pid); if (!p) return;\n    if (m.t === 'p') setR(p, m.x, m.z, m.yaw, m.pitch, m.c, m.l, m.g, 1, m.h, m.y);\n    else if (m.t === 'w') {\n      p.r.vis = !!m.on;\n      if (!m.on && match.on && match.phase === 'live' && !p.out) { p.out = true; net.send({ t: 'o', v: p.pid }); badge(); checkRoundEnd(); }\n    }\n    else if (m.t === 's' && Array.isArray(m.o) && Array.isArray(m.v)) {\n      const ci = cl(m.ci | 0, 0, BALLC.length - 1);\n      launch(new THREE.Vector3(...nums(m.o)), new THREE.Vector3(...nums(m.v)), ci, false);\n      stat(c.pid).s++; kickSlide(c.pid);\n      relay({ t: 's', o: m.o, v: m.v, ci, k: cl(m.k | 0, 0, 2), f: c.pid }, c);\n    }\n    else if (m.t === 'h') { const v = m.v | 0; if (onHit(c.pid, v)) relay({ t: 'h', v, f: c.pid }, c); }\n    else if (m.t === 'wc') { p.wc = parseWc(m.w); if (p.grp) buildAvatar(p); sendRoster(); } \/\/ a guest changed their weapon colours\n    else if (m.t === 'team') { hostSetTeam(c.pid, m.v ? 1 : 0); sendRoster(); replaceAll(); renderRoster(); badge(); updateLabels(); try { c.send(marksMsg(p.team, p.coach)); } catch (e) {} }\n    else if (m.t === 'slot') { hostSlot(c.pid, m.v | 0); sendRoster(); renderRoster(); }\n    else if (m.t === 'g') flagAction(c.pid);\n    else if (m.t === 'qa') qRecord(c.pid, m.q | 0, !!m.k);\n    else if (m.t === 'cp') { try { c.send({ t: 'cq', c: m.c, s: performance.now() }); } catch (e) {} c.ping = Math.min(+m.p || 0, 5000); }\n    else if (m.t === 'qj') { if (m.v) quiz.joined.add(c.pid); else quiz.joined.delete(c.pid); qCheck(); }\n    else if (m.t === 'role') {\n      const ok = hostSetRole(c.pid, !!m.c);\n      sendRoster(); replaceAll(); renderRoster(); badge(); updateMatchUI(); hostStatus();\n      try { c.send({ t: 'you', co: p.coach ? 1 : 0, tm: p.team, ok: ok ? 1 : 0 }); } catch (e) {}\n    }\n    else if (p.coach && (m.t === 'dk' || m.t === 'dx' || m.t === 'dc')) hostMark(c, p, m);\n  }\n  function hostDrop(c) {\n    if (!net.host || !c.pid) return;\n    const pid = c.pid; c.pid = 0;\n    net.conns.delete(pid); removePlayer(pid);\n    sendRoster(); renderRoster(); badge(); updateMatchUI(); hostStatus();\n    if (match.on) checkRoundEnd();\n  }\n\n  \/\/ guest side: messages from the host\n  function onMsg(m) {\n    if (!m || typeof m !== 'object') return;\n    if (m.t === 'hello') {\n      net.pid = m.pid | 0; net.team = m.team ? 1 : 0; $('.n-team').value = net.team; net.coach = !!m.co; $('.n-coach').checked = net.coach;\n      nstat(net.coach ? '\u2705 Connected as <b>' + TN[net.team] + '<\/b> spectator. Drag on the field to draw marks.' : '\u2705 Connected as <b>' + TN[net.team] + '<\/b>. Press <b>Walk<\/b> to play.');\n      coachUI(net.coach); clkStart();\n      $('.n-leave').hidden = false; badge(); updateMatchUI();\n      if (fp.on) netWalk(true);\n    }\n    else if (m.t === 'full') { leave(true); nstat('This room is full..'); }\n    else if (m.t === 'roster' && Array.isArray(m.l)) syncRoster(m.l);\n    else if (m.t === 'field') { if (net.guest && typeof m.s === 'string' && m.s.length < 2e6 && load(m.s)) setLocked(true, true); }\n    else if (m.t === 'st' && Array.isArray(m.l)) { if (net.guest) { stats.clear(); m.l.slice(0, 40).forEach(e => { if (Array.isArray(e)) stats.set(e[0] | 0, { k: e[1] | 0, d: e[2] | 0, s: e[3] | 0, h: e[4] | 0, n: e[5] | 0 }); }); } }\n    else if (m.t === 'ps' && Array.isArray(m.l)) {\n      m.l.forEach(e => { if (!Array.isArray(e) || e[0] === net.pid) return; const p = net.players.get(e[0]); if (p) setR(p, e[1], e[2], e[3], e[4], e[5], e[6], e[7], e[8], e[9], e[10]); });\n    }\n    else if (m.t === 'mode') { if (net.guest && (m.m === 'free' || m.m === 'match' || m.m === 'buzzer' || m.m === 'flag' || m.m === 'flag7')) { $('.n-mode').value = m.m; stopMatch(false); updateMatchUI(); updateLabels(); } }\n    else if (m.t === 'round') { if (net.guest) startRound(!!m.r, net.clk.ok ? +m.at : NaN); }\n    else if (m.t === 'cq') { if (net.guest) clkSample(m); }\n    else if (m.t === 'pg') { if (net.guest && Array.isArray(m.l)) applyPing(m.l); }\n    else if (m.t === 'bal') { if (net.guest) { setHud(esc(String(m.m || '').slice(0, 200))); setTimeout(hint, 4000); } }\n    else if (m.t === 'place') { if (net.guest && match.on && match.phase === 'count' && fp.on && !net.coach) placeAtStart(); }\n    else if (m.t === 'late') { match.on = true; match.phase = 'wait'; hideCount(); showCount('Wait for the next round\u2026', true); updateMatchUI(); updateLabels(); badge(); }\n    else if (m.t === 'rend') roundEnd(m.w | 0, !!m.b);\n    else if (m.t === 'fl') { if (net.guest) { fl.st = m.s | 0; fl.c = m.c | 0; fl.x = +m.x || 0; fl.z = +m.z || 0; fl.att = m.a ? 1 : 0; fl.tn = (m.tn | 0) || 1; fl.rn = (m.rn | 0) || 1; fl.tt = +m.tt || 0; fl.bz = m.bz >= 0 ? +m.bz : -1; fl.bonus = !!m.bn; fl.pts = [+m.p0 || 0, +m.p1 || 0]; net.score.t = fl.pts.slice(); } }\n    else if (m.t === 'fend') flagEndUI(String(m.m || '').slice(0, 200));\n    else if (m.t === 'you') {\n      if (!net.guest) return;\n      net.coach = !!m.co; $('.n-coach').checked = net.coach; net.team = m.tm ? 1 : 0; $('.n-team').value = net.team;\n      if (!m.ok) { nstat('You cannot switch role now (a round is running, or the team is full).'); setHud('\ud83d\udd12 You cannot switch now (a round is running, or the team is full).'); setTimeout(hint, 2500); }\n      if (net.coach && fp.on) exitWalk(true);\n      coachUI(net.coach); if (!net.coach) hint();\n      if (!net.coach && match.on && match.phase === 'count' && !fp.on) enterWalk(); \/\/ became a player during the pause\n    }\n    else if (m.t === 'dk') { if (net.guest) applyMark(m, audOf(m.f), -1); }\n    else if (m.t === 'dx') { if (net.guest) removeMark(+m.id); }\n    else if (m.t === 'dc') { if (net.guest) clearMarks(audOf(m.f)); }\n    else if (m.t === 'dall') { if (net.guest) loadMarks(m.l); }\n    else if (m.t === 'stop') stopMatch(false);\n    else if (m.t === 'qz' || m.t === 'qq' || m.t === 'qs') qRemote(m);\n    else if (m.t === 'o') { const q = net.players.get(m.v | 0); if (q) { q.out = true; badge(); } }\n    else if (m.t === 's' && Array.isArray(m.o) && Array.isArray(m.v)) { launch(new THREE.Vector3(...nums(m.o)), new THREE.Vector3(...nums(m.v)), cl(m.ci | 0, 0, BALLC.length - 1), false); kickSlide(m.f | 0); }\n    else if (m.t === 'h') onHit(m.f | 0, m.v | 0);\n  }\n\n  function netWalk(on) { net.tSend = 0; if (!net.host) net.send({ t: 'w', on: !!on }); }\n  function updateNet(dt) {\n    const on = ncon();\n    if (on) {\n      net.tSend -= dt;\n      if (net.tSend <= 0) {\n        net.tSend = .075;\n        if (net.host) { \/\/ one batched update with everybody's position goes to every guest\n          const l = [];\n          net.players.forEach(p => {\n            if (p.pid === net.pid) l.push([p.pid, +fp.x.toFixed(2), +fp.z.toFixed(2), +fp.yaw.toFixed(3), +fp.pitch.toFixed(3), fp.prone ? 2 : (fp.keys.KeyC || fp.slide) ? 1 : 0, +fp.lean.toFixed(2), WLIST.indexOf(weapon), fp.on ? 1 : 0, fp.hand, +(fp.y + fp.gy).toFixed(2)]);\n            else { const r = p.r; l.push([p.pid, +r.x.toFixed(2), +r.z.toFixed(2), +r.yaw.toFixed(3), +r.pitch.toFixed(3), r.prone ? 2 : r.crouch ? 1 : 0, +r.lean.toFixed(2), WLIST.indexOf(r.wpn), r.vis ? 1 : 0, r.hand, +r.y.toFixed(2)]); }\n          });\n          net.send({ t: 'ps', l });\n          if (isFlag() && match.on) net.send({ t: 'fl', s: fl.st, c: fl.c, x: +fl.x.toFixed(2), z: +fl.z.toFixed(2), a: fl.att, tn: fl.tn, rn: fl.rn, p0: fl.pts[0], p1: fl.pts[1], tt: +fl.tt.toFixed(1), bz: fl.bz >= 0 ? +fl.bz.toFixed(1) : -1, bn: fl.bonus ? 1 : 0 });\n        } else if (fp.on) {\n          net.send({ t: 'p', x: +fp.x.toFixed(2), z: +fp.z.toFixed(2), yaw: +fp.yaw.toFixed(3), pitch: +fp.pitch.toFixed(3), c: fp.prone ? 2 : (fp.keys.KeyC || fp.slide) ? 1 : 0, l: +fp.lean.toFixed(2), g: WLIST.indexOf(weapon), h: fp.hand, y: +(fp.y + fp.gy).toFixed(2) });\n        }\n      }\n    }\n    if (on && net.host) { net.stT = (net.stT || 0) - dt; if (net.stT <= 0) { net.stT = .5; const l = []; stats.forEach((o, pid) => l.push([pid, o.k, o.d, o.s, o.h, o.n])); net.send({ t: 'st', l }); } }\n    const k = Math.min(1, dt * 12);\n    net.players.forEach(p => {\n      const g = p.grp; if (!g) return;\n      const r = p.r, u = g.userData;\n      g.visible = on && r.vis && !(match.on && p.out);\n      if (!g.visible) return;\n      const ox = g.position.x, oz = g.position.z;\n      g.position.x += (r.x - g.position.x) * k; g.position.z += (r.z - g.position.z) * k;\n      const spd = Math.hypot(g.position.x - ox, g.position.z - oz) \/ Math.max(dt, 1e-3); \/\/ how fast the figure is moving: drives the walk cycle\n      const dy = Math.atan2(Math.sin(r.yaw - g.rotation.y), Math.cos(r.yaw - g.rotation.y));\n      g.rotation.y += dy * k;\n      if (u.wpn !== r.wpn) { u.wpn = r.wpn; const S = { m4: [1, 1, 1.5, -.45], pistol: [1, 1, .3, -.12] }[r.wpn] || [1, 1, 1.5, -.45]; u.gmesh.scale.set(S[0], S[1], S[2]); u.gmesh.position.z = S[3]; const isR = r.wpn === 'm4'; u.gmesh.visible = false; u.rifle.visible = isR; u.pist.visible = !isR; u.hp = isR ? [[-.13, -.34], [-.03, -.58]] : [[-.045, -.255], [-.065, -.235]]; }\n      u.slideK = Math.max(0, u.slideK - dt * 9); u.pslide.position.z = .03 * (u.slideK > .7 ? (1 - u.slideK) \/ .3 : u.slideK \/ .7); \/\/ pistol slide kicks back when this player shoots\n      const dn = gunDown() * 1.05; \/\/ start of a round: the marker is held down on the start zone and swings up after GO\n      u.piv.rotation.x = r.pitch; const cs = 1; \/\/ crouching is a real one-knee kneel now (see below), the body is no longer squashed\n      if (u.cs !== cs) { u.cs = cs; u.legs.scale.y = u.body.scale.y = cs; u.legs.position.y = .3 * cs; u.body.position.y = .3 * cs; u.inner.position.y = .6 * cs; u.head.position.y = .85 * cs; u.lb.position.y = 2.2 * cs; }\n      { \/\/ one-knee kneel: the hips drop and the torso stays upright, so the head ends up at the (lower) eye height. One knee on the ground, the other foot flat in front\n        const wc = WEAPONS[r.wpn], kt = r.crouch && !r.prone ? 1 : 0;\n        u.kn = (u.kn || 0) + (kt - (u.kn || 0)) * Math.min(1, k * .9);\n        const eyeNow = 1.7 + (wc.crouch - 1.7) * u.kn, hipY = Math.max(.15, 1.45 + (eyeNow - 1.7) \/ 1.12 - .85);\n        u.hipY = hipY; u.phi = 0; u.legs.position.set(0, hipY - .3, 0); u.inner.position.set(0, hipY + Math.sin(u.ph * 2) * .012 * u.amp * u.kn, 0); u.inner.rotation.x = 0;\n        const st = (eyeNow - .17) \/ 1.12 - hipY; \/\/ the marker is held exactly where the shots come from: 0.17 m under the eyes. Lying down it is held out in front of the shoulders, along the ground\n        u.piv.position.y = st + (.6 - st) * u.pr; u.piv.position.z = 0;\n      }\n      u.piv.rotation.x = r.pitch - dn + (u.phi || 0) + Math.PI \/ 2 * u.pr; u.piv.position.y -= dn * .2; \/\/ lying down the body is tipped over, so the gun is turned the same way and keeps pointing where the player looks\n      u.pr += ((r.prone ? 1 : 0) - u.pr) * Math.min(1, k * 1.2); u.fig.rotation.x = -Math.PI \/ 2 * u.pr; u.fig.position.y = .27 * u.pr; u.fig.position.z = 1.62 * u.pr; \/\/ lie down flat, head forward, body trailing behind\n      u.lb.position.y = r.prone ? .6 : 2.2 + (WEAPONS[r.wpn].crouch - 1.7) * (u.kn || 0);\n      g.position.y += ((r.y || 0) - g.position.y) * k; u.piv.position.x += (.18 * (r.hand || 1) - u.piv.position.x) * k; \/\/ jumping + left\/right-handed opponents\n      u.lean += (r.lean - u.lean) * k; u.inner.rotation.z = -u.lean * .35; \/\/ opponents visibly peek\n      \/\/ walk cycle: thigh swings, the knee bends as the leg comes forward; arms reach for the marker with a small bob\n      u.amp += (cl(spd * .25, 0, .95) * (spd > .15 ? 1 : 0) - u.amp) * k; u.ph += spd * dt * 2.6;\n      { \/\/ crouch-walk state: a smoothed speed drives its own step phase. u.sk = how far leg 0 is planted (1) rather than kneeling (0)\n        u.spdS = (u.spdS || 0) + (spd - (u.spdS || 0)) * Math.min(1, dt * 7);\n        if (u.sk === undefined) u.sk = (r.hand || 1) > 0 ? 1 : 0;\n        if (u.spdS > .25) { u.cph = (u.cph || 0) + Math.min(u.spdS, 2.5) * dt * 5.2; u.skT = cl(.5 + Math.sin(u.cph) * 1.1, 0, 1); }\n        else u.skT = u.sk > .5 ? 1 : 0; \/\/ stopped: finish the step in progress and stay on the knee that is down\n        u.sk += (u.skT - u.sk) * Math.min(1, dt * (u.spdS > .25 ? 16 : 9));\n      }\n      [0, 1].forEach(i => {\n        const ph = u.ph + i * Math.PI, hip = u.legP[i];\n        hip.rotation.x = Math.sin(ph) * u.amp * .9;\n        hip.userData.knee.rotation.x = -(.12 * u.amp + 1.15 * u.amp * Math.max(0, Math.cos(ph))); \/\/ shin folds back while the leg swings forward\n        if (u.kn > .001) { \/\/ kneeling: the leg on the marker side kneels, the other foot is flat in front (thigh level, shin upright)\n          const fr = i === ((r.hand || 1) > 0 ? 0 : 1);\n          const hy = u.hipY, a = Math.acos(cl((hy - .315) \/ .3, -1, 1)), b = Math.acos(cl((hy - .06) \/ .3, -1, 1)); \/\/ front foot flat (shin upright), back knee on the ground (shin along the floor)\n          \/\/ the legs swap roles every step: the knee leg swings forward (foot lifted over the ground) and becomes the planted foot while the planted leg drops onto its knee. Standing still keeps whichever knee is down\n          const sl = i === 0 ? u.sk : 1 - u.sk, s = sl * sl * (3 - 2 * sl); \/\/ s: 0 = on the knee, 1 = foot planted in front\n          const tH = b + (a - b) * s, tK = -(Math.PI \/ 2 + b) + (Math.PI \/ 2 + b - a) * s - .7 * Math.sin(Math.PI * s);\n          hip.rotation.x += (tH - hip.rotation.x) * u.kn; hip.userData.knee.rotation.x += (tK - hip.userData.knee.rotation.x) * u.kn;\n        }\n      });\n      const cz = u.cs || 1, hd = r.hand || 1, pp = u.piv.position, sn = Math.sin(r.pitch - dn + (u.phi || 0) + Math.PI \/ 2 * u.pr), cn = Math.cos(r.pitch - dn + (u.phi || 0) + Math.PI \/ 2 * u.pr), bob = Math.sin(u.ph * 2) * .014 * u.amp, sway = Math.sin(u.ph) * .012 * u.amp;\n      [[hd, 0], [-hd, 1]].forEach(([side, i]) => {\n        const yh = u.hp[i][0], zh = u.hp[i][1];\n        const a = u.A[i];\n        _aS.set(.17 * side, .58 * cz, 0); _aH.set(pp.x + sway * (i ? -1 : 1), pp.y + yh * cn - zh * sn + bob, pp.z + yh * sn + zh * cn);\n        a.hand.position.copy(_aH); arm2(a.up, a.fo, a.el, _aS, _aH, side);\n      });\n      u.lb.visible = !match.on || p.team === net.team; \/\/ enemy name tags would give away their position in a match\n    });\n  }\n\n  \/* ---------- public server list ---------- *\/\n  \/\/ The list lives on your WordPress site (plugin \"Paintball Callout - Server List\"). If the API is on another domain, set window.PBF_API = 'https:\/\/your-site.com\/wp-json\/pbf\/v1' in a separate script block above the game.\n  const PBF_API = String(window.PBF_API || '\/wp-json\/pbf\/v1').replace(\/\\\/$\/, '');\n  const pub = { token: '', timer: 0, kick: 0, busy: false };\n  let pbfBase = null; \/\/ the address style that worked (pretty permalinks \/wp-json\/... or the plain ?rest_route=... form)\n  async function pbfCall(path, opt, query) { \/\/ tries both WordPress REST address styles; throws an Error with .status and a readable message\n    const bases = [PBF_API]; if (\/\\\/wp-json\\\/pbf\\\/v1$\/.test(PBF_API)) bases.push(PBF_API.replace(\/\\\/wp-json\\\/pbf\\\/v1$\/, '') + '\/?rest_route=\/pbf\/v1');\n    if (pbfBase) bases.sort((a, b) => (a === pbfBase ? -1 : b === pbfBase ? 1 : 0));\n    let last = { status: 0, msg: 'no connection' };\n    for (const b of bases) {\n      try {\n        const r = await fetch(b + path + (query ? (b.indexOf('?') >= 0 ? '&' : '?') + query : ''), opt);\n        const txt = await r.text(); let j = null; try { j = JSON.parse(txt); } catch (e) {}\n        if (j && typeof j === 'object' && j.code !== 'rest_no_route') { pbfBase = b; return { r, j }; }\n        last = { status: r.status, msg: (j && j.message) || (r.status === 404 ? 'plugin not found at ' + b : 'answer was not JSON') };\n      } catch (e) { last = { status: 0, msg: 'could not reach ' + b }; }\n    }\n    const er = new Error(last.msg); er.status = last.status; throw er;\n  }\n  const SV_MODE = { free: 'Free play', match: 'Match', buzzer: 'Buzzer', flag7: 'Flag' };\n  const pubWanted = () => net.host && $('.n-pub').checked;\n  async function pubBeat() {\n    if (!pubWanted() || !net.code || pub.busy) return;\n    pub.busy = true;\n    try {\n      const pl = Array.from(net.players.values()), nm = ($('.n-sname').value || '').replace(\/[<>&\"]\/g, '').trim().slice(0, 24) || (myName() ? myName() + \"'s game\" : 'Open game');\n      const { r, j } = await pbfCall('\/server', { method: 'POST', headers: { 'Content-Type': 'application\/json' }, cache: 'no-store', keepalive: true,\n        body: JSON.stringify({ code: net.code, token: pub.token, name: nm, mode: $('.n-mode').value, players: pl.filter(p => !p.coach).length, spec: pl.filter(p => p.coach).length, size: W + 'x' + L, ind: indoor ? 1 : 0 }) });\n      if (r.ok && j && j.token) { pub.token = j.token; pubNote(true); } else pubNote(false, (j && (j.error || j.message)) || 'HTTP ' + r.status);\n    } catch (e) { pubNote(false, e.message + (e.status ? ' (HTTP ' + e.status + ')' : '')); }\n    pub.busy = false;\n  }\n  function pubNote(ok, why) {\n    const el = $('.n-status'); if (!net.host || !el) return;\n    el.querySelectorAll('.pub-note').forEach(x => x.remove());\n    if (!pubWanted()) return;\n    const d = document.createElement('div'); d.className = 'pub-note';\n    d.innerHTML = ok ? '\ud83c\udf0d Your game is listed in the server list.' : '\u26a0 Could not list your game' + (why ? ' (' + esc(String(why)) + ')' : ' (server list not reachable)') + '. Friends can still join with the room code.';\n    el.appendChild(d);\n  }\n  function pubStart() { pubStop(true); if (!pubWanted()) return; pubBeat(); pub.timer = setInterval(pubBeat, 20000); }\n  function pubStop(keepToken) {\n    clearInterval(pub.timer); pub.timer = 0; clearTimeout(pub.kick);\n    if (pub.token && net.code && !keepToken) {\n      const body = JSON.stringify({ code: net.code, token: pub.token });\n      try { const cu = (pbfBase || PBF_API) + '\/server\/close'; if (navigator.sendBeacon) navigator.sendBeacon(cu, new Blob([body], { type: 'application\/json' })); else fetch(cu, { method: 'POST', headers: { 'Content-Type': 'application\/json' }, body, keepalive: true }); } catch (e) {}\n    }\n    if (!keepToken) pub.token = '';\n  }\n  const pubKick = () => { if (!pub.timer) return; clearTimeout(pub.kick); pub.kick = setTimeout(pubBeat, 1500); }; \/\/ the roster \/ mode changed: update the list soon\n  try { const sv = JSON.parse(localStorage.getItem('pbf_pub') || 'null'); if (sv) { $('.n-pub').checked = !!sv.on; $('.n-sname').value = String(sv.n || '').slice(0, 24); } } catch (e) {}\n  const pubUI = () => { $('.n-snw').hidden = !$('.n-pub').checked; };\n  const pubSave = () => { try { localStorage.setItem('pbf_pub', JSON.stringify({ on: $('.n-pub').checked, n: $('.n-sname').value })); } catch (e) {} };\n  $('.n-pub').onchange = () => { pubUI(); pubSave(); if (net.host) { if ($('.n-pub').checked) pubStart(); else { pubStop(); pubNote(false); } } };\n  $('.n-sname').onchange = () => { pubSave(); pubKick(); };\n  pubUI();\n  async function listServers() {\n    const box = $('.n-servers'), b = $('.n-list'); b.disabled = true;\n    try {\n      const { r, j } = await pbfCall('\/servers', { cache: 'no-store' }, '_=' + Date.now()); if (!r.ok || !j || !Array.isArray(j.servers)) throw new Error((j && j.message) || 'HTTP ' + r.status);\n      const l = j.servers.filter(s => s && \/^[A-Z0-9]{4,6}$\/.test(s.code) && s.code !== net.code);\n      box.innerHTML = l.length ? '' : '<div class=\"sv-none\">No open servers right now. Host one yourself with \u201cPublic server\u201d ticked.<\/div>';\n      l.forEach(s => {\n        const d = document.createElement('div'); d.className = 'sv';\n        const mm = Math.floor((+s.age || 0) \/ 60);\n        d.innerHTML = '<div><b>' + esc(String(s.name || 'Open game').slice(0, 24)) + '<\/b><span>' + esc(SV_MODE[s.mode] || 'Free play') + ' \u00b7 ' + (s.size ? esc(String(s.size).replace('x', '\u00d7')) + ' m \u00b7 ' : '') + (s.ind ? 'indoor \u00b7 ' : '') + (+s.players | 0) + '\/' + (+s.max || 10) + ' players' + (+s.spec ? ' + ' + (+s.spec | 0) + ' spec' : '') + ' \u00b7 ' + (mm < 1 ? 'just opened' : mm + ' min') + '<\/span><\/div>';\n        const jb = document.createElement('button'); jb.textContent = 'Join'; jb.disabled = (+s.players | 0) >= (+s.max || 10) && !$('.n-coach').checked;\n        jb.onclick = () => { $('.n-code').value = s.code; joinGame(); };\n        d.appendChild(jb); box.appendChild(d);\n      });\n    } catch (e) { box.innerHTML = '<div class=\"sv-none\">The server list is not available right now (' + esc(String(e.message || 'error').slice(0, 120)) + '). You can still join with a room code.<\/div>'; }\n    b.disabled = false;\n  }\n  $('.n-list').onclick = listServers;\n  setInterval(() => { if (mode === 'play' && sdTab.play === 'net' && !ncon() && !document.hidden) listServers(); }, 30000);\n\n  function leave(silent) {\n    pubStop(); clearTimeout(net.fieldTimer); stopMatch(false);\n    const cs = Array.from(net.conns.values()), c = net.conn, p = net.peer, wasGuest = net.guest;\n    net.conns = new Map(); net.conn = null; net.peer = null; net.host = false; net.guest = false; net.code = ''; net.pid = 0; net.coach = false; coachUI(false); clearMarks();\n    Array.from(net.players.keys()).forEach(removePlayer);\n    net.score = { me: 0, t: [0, 0] }; net.team = +$('.n-team').value ? 1 : 0;\n    $('.pbf-net').hidden = true; $('.n-leave').hidden = true; $('.n-roster').innerHTML = ''; stats.clear();\n    $('.n-host').disabled = false; $('.n-join').disabled = false; updateMatchUI();\n    cs.forEach(x => { try { x.close(); } catch (e) {} });\n    try { if (c) c.close(); } catch (e) {}\n    try { if (p) p.destroy(); } catch (e) {}\n    if (!silent) nstat('You left the game.');\n    if (wasGuest) { \/\/ bring back your own saved field\n      let sv = null; try { sv = localStorage.getItem(KEY); } catch (e) {}\n      if (!(sv && load(sv))) setLocked(false, true);\n    }\n  }\n  function ensurePeer(cb) {\n    if (window.Peer) return cb(true);\n    const s = document.createElement('script');\n    s.src = 'https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/peerjs\/1.5.4\/peerjs.min.js';\n    s.onload = () => cb(!!window.Peer); s.onerror = () => cb(false);\n    document.head.appendChild(s);\n  }\n  const rndCode = () => { const a = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let c = ''; for (let i = 0; i < 5; i++) c += a[Math.floor(Math.random() * a.length)]; return c; };\n  function startHost(tries) {\n    net.code = rndCode(); net.host = true; net.guest = false; net.pid = 0; net.conns = new Map();\n    Array.from(net.players.keys()).forEach(removePlayer);\n    net.team = +$('.n-team').value ? 1 : 0; net.coach = $('.n-coach').checked; addPlayer(0, net.team, myName() || 'Host', net.coach); renderRoster(); coachUI(net.coach);\n    const p = new Peer('pbf-' + net.code); net.peer = p;\n    p.on('open', () => { hostStatus(); $('.n-leave').hidden = false; pubStart(); });\n    p.on('connection', hostAccept);\n    p.on('error', err => {\n      if (net.peer !== p) return;\n      if (err.type === 'unavailable-id' && tries < 4) { try { p.destroy(); } catch (e) {} startHost(tries + 1); return; }\n      leave(true); nstat('Could not start a game (' + esc(err.type || 'error') + '). Try again.');\n    });\n  }\n  $('.n-host').onclick = () => {\n    leave(true); nstat('Creating room\u2026'); $('.n-host').disabled = $('.n-join').disabled = true;\n    ensurePeer(ok => { if (!ok) { leave(true); nstat('The online library could not be loaded.'); return; } $('.n-host').disabled = $('.n-join').disabled = false; startHost(0); });\n  };\n  function joinGame() {\n    const code = $('.n-code').value.trim().toUpperCase().replace(\/[^A-Z0-9]\/g, '');\n    if (code.length < 4) { nstat('Type the room code from your host first.'); return; }\n    leave(true); nstat('Connecting\u2026');\n    ensurePeer(ok => {\n      if (!ok) { nstat('The online library could not be loaded.'); return; }\n      net.guest = true; net.host = false; net.code = code; net.pid = -1;\n      const p = new Peer(); net.peer = p;\n      p.on('open', () => {\n        if (net.peer !== p) return;\n        const c = p.connect('pbf-' + code, { reliable: true }); net.conn = c;\n        c.on('open', () => { try { c.send({ t: 'join', n: myName(), c: $('.n-coach').checked ? 1 : 0, tm: +$('.n-team').value ? 1 : 0, w: wcArr(wcol) }); } catch (e) {} });\n        c.on('data', onMsg);\n        c.on('close', () => { if (c !== net.conn) return; leave(true); nstat('The host closed the game.'); });\n        c.on('error', () => {});\n      });\n      p.on('error', err => {\n        if (net.peer !== p) return;\n        const msg = err.type === 'peer-unavailable' ? 'Room <b>' + esc(code) + '<\/b> was not found. Check the code and ask the host to keep the game open.' : 'Connection problem (' + esc(err.type || 'error') + ').';\n        leave(true); nstat(msg);\n      });\n      setTimeout(() => { if (net.peer === p && !ncon()) { leave(true); nstat('Could not connect. Check the code, or a strict network may be blocking the connection.'); } }, 15000);\n    });\n  }\n  $('.n-join').onclick = joinGame;\n  $('.n-code').onkeydown = e => { if (e.key === 'Enter') joinGame(); };\n  $('.n-leave').onclick = () => leave(false);\n  window.addEventListener('pagehide', () => { try { pubStop(); } catch (e) {} });\n  window.addEventListener('beforeunload', () => { try { if (net.peer) net.peer.destroy(); } catch (e) {} });\n\n  \/* ---------- coach: draw live marks on the ground for your own team ---------- *\/\n  const COACH_COLS = ['#ffe14d', '#ffffff', '#ff4fa3', '#35e0ff', '#8dff4f', '#111111'];\n  const marks = new Map(), markG = new THREE.Group(); scene.add(markG);\n  const coach = { draw: true, col: COACH_COLS[0], n: 0, cur: null, pid: -1, sent: 0, t: 0 };\n  const okCol = c => \/^#[0-9a-f]{6}$\/i.test(c) ? c : COACH_COLS[0];\n  const fxz = v => Math.round(v * 100) \/ 100;\n  function markGeo(k) { \/\/ flat ribbon on the floor (a dot when the stroke is a single point, optional arrowhead)\n    const P = k.pts, n = P.length \/ 2, w = .125, pos = [], idx = [];\n    const quad = (ax, az, bx, bz) => {\n      const dx = bx - ax, dz = bz - az, l = Math.hypot(dx, dz) || 1, nx = -dz \/ l * w, nz = dx \/ l * w, i = pos.length \/ 3;\n      pos.push(ax + nx, 0, az + nz, ax - nx, 0, az - nz, bx - nx, 0, bz - nz, bx + nx, 0, bz + nz); idx.push(i, i + 1, i + 2, i, i + 2, i + 3);\n    };\n    if (n === 1) {\n      pos.push(P[0], 0, P[1]); for (let q = 0; q <= 16; q++) { const a = q \/ 16 * 6.2832; pos.push(P[0] + Math.cos(a) * .3, 0, P[1] + Math.sin(a) * .3); }\n      for (let q = 1; q <= 16; q++) idx.push(0, q, q + 1);\n    } else if (n > 1) {\n      for (let i = 0; i < n - 1; i++) quad(P[2 * i], P[2 * i + 1], P[2 * i + 2], P[2 * i + 3]);\n      if (k.ar) {\n        const bx = P[2 * n - 2], bz = P[2 * n - 1]; let j = n - 2;\n        while (j > 0 && Math.hypot(P[2 * j] - bx, P[2 * j + 1] - bz) < .6) j--;\n        const dx = bx - P[2 * j], dz = bz - P[2 * j + 1], l = Math.hypot(dx, dz) || 1, ux = dx \/ l, uz = dz \/ l;\n        [1, -1].forEach(sg => { const c = Math.cos(.52), sn = Math.sin(.52) * sg; quad(bx, bz, bx + (-ux * c + uz * sn) * .9, bz + (-ux * sn - uz * c) * .9); });\n      }\n    }\n    const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); return g;\n  }\n  function markRebuild(k) { k.mesh.geometry.dispose(); k.mesh.geometry = markGeo(k); }\n  function removeMark(id) {\n    const k = marks.get(id); if (!k) return;\n    markG.remove(k.mesh); k.mesh.geometry.dispose(); k.mesh.material.dispose(); marks.delete(id);\n  }\n  const markShow = k => net.coach || k.tm === 2 || k.tm === net.team; \/\/ who sees a mark: its audience (Red \/ Blue \/ both); coaches see all\n  function addMark(id, tm, col, ar, pts, own) {\n    if (marks.has(id)) removeMark(id);\n    const k = { id, tm, own, col, ar, pts, mesh: new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshBasicMaterial({ color: col, side: THREE.DoubleSide })) };\n    k.mesh.position.y = .1; k.mesh.renderOrder = 2; k.mesh.visible = markShow(k);\n    markG.add(k.mesh); marks.set(id, k); markRebuild(k);\n    while (marks.size > 80) removeMark(marks.keys().next().value);\n    return k;\n  }\n  function clearMarks(tm) { Array.from(marks.values()).forEach(k => { if (tm === undefined || tm === 2 || k.tm === tm) removeMark(k.id); }); }\n  function markVis() { marks.forEach(k => { k.mesh.visible = markShow(k); }); }\n  function applyMark(m, tm, own) { \/\/ a (partial) stroke from a coach\n    const id = +m.id; if (!isFinite(id)) return;\n    let k = marks.get(id);\n    if (!k) { if (+m.o !== 0) return; k = addMark(id, tm, okCol(m.c), !!m.a, [], own); }\n    if (k.tm !== tm || (own >= 0 && k.own !== own)) return;\n    k.ar = !!m.a;\n    const o = +m.o | 0;\n    if (Array.isArray(m.p) && o * 2 === k.pts.length) m.p.slice(0, 400).forEach(v => k.pts.push(cl(fxz(+v || 0), -100, 100)));\n    if (k.pts.length % 2) k.pts.pop();\n    if (k.pts.length > 1000) k.pts.length = 1000;\n    markRebuild(k);\n  }\n  function marksMsg(tm, all) { return { t: 'dall', l: Array.from(marks.values()).filter(k => all || k.tm === 2 || k.tm === tm).map(k => ({ i: k.id, f: k.tm, c: k.col, a: k.ar ? 1 : 0, p: k.pts })) }; } \/\/ coaches get every mark\n  function loadMarks(l) {\n    clearMarks(); if (!Array.isArray(l)) return;\n    l.slice(0, 80).forEach(e => { const id = +e.i; if (!isFinite(id) || !Array.isArray(e.p)) return; const pts = e.p.slice(0, 1000).map(v => cl(fxz(+v || 0), -100, 100)); if (pts.length % 2) pts.pop(); addMark(id, e.f === 2 ? 2 : (+e.f ? 1 : 0), okCol(e.c), !!e.a, pts, -1); });\n  }\n  function relayTeam(o, skip, aud) { \/\/ send to every coach and to the players of the audience (0 red, 1 blue, 2 both)\n    net.conns.forEach(c => { if (c === skip || !c.open || !c.pid) return; const q = net.players.get(c.pid); if (q && (q.coach || aud === 2 || q.team === aud)) { try { c.send(o); } catch (e) {} } });\n  }\n  const audOf = f => f === 2 ? 2 : (+f ? 1 : 0);\n  function hostMark(c, p, m) { \/\/ host: a coach drew, removed or cleared something\n    if (m.t === 'dk') { const a = audOf(m.f); applyMark(m, a, p.pid); relayTeam({ t: 'dk', id: m.id, f: a, c: m.c, a: m.a, o: m.o, p: m.p }, c, a); }\n    else if (m.t === 'dx') { const k = marks.get(+m.id); if (k && k.own === p.pid) { const a = k.tm; removeMark(k.id); relayTeam({ t: 'dx', id: k.id }, c, a); } }\n    else { const a = audOf(m.f); clearMarks(a); relayTeam({ t: 'dc', f: a }, c, a); }\n  }\n  const markSend = o => { if (net.host) relayTeam(o, null, audOf(o.f)); else net.send(o); };\n  function coachFlush() {\n    const k = coach.cur; if (!k) return; coach.t = performance.now();\n    const n = k.pts.length \/ 2; if (n <= coach.sent) return;\n    markSend({ t: 'dk', id: k.id, f: k.tm, c: k.col, a: k.ar ? 1 : 0, o: coach.sent, p: k.pts.slice(coach.sent * 2) }); coach.sent = n;\n  }\n  function coachDown(e) {\n    if (!(net.coach && coach.draw) || e.button !== 0 || e.shiftKey || e.ctrlKey || e.metaKey || !e.isPrimary) return false;\n    pick(e); const gp = groundPoint(); if (!gp) return true;\n    try { canvas.setPointerCapture(e.pointerId); } catch (er) {}\n    coach.cur = addMark(net.pid * 100000 + (++coach.n), audOf(+$('.c-for').value), coach.col, $('.c-arrow').checked, [fxz(cl(gp.x, -W \/ 2, W \/ 2)), fxz(cl(gp.z, -L \/ 2, L \/ 2))], net.pid);\n    coach.pid = e.pointerId; coach.sent = 0; coachFlush();\n    return true;\n  }\n  canvas.addEventListener('pointermove', e => {\n    const k = coach.cur; if (!k || e.pointerId !== coach.pid) return;\n    pick(e); const gp = groundPoint(); if (!gp) return;\n    const P = k.pts, x = fxz(cl(gp.x, -W \/ 2, W \/ 2)), z = fxz(cl(gp.z, -L \/ 2, L \/ 2));\n    if (P.length >= 1000 || Math.hypot(x - P[P.length - 2], z - P[P.length - 1]) < .15) return;\n    P.push(x, z); markRebuild(k);\n    if (performance.now() - coach.t > 80) coachFlush();\n  });\n  const coachUp = e => { if (!coach.cur || e.pointerId !== coach.pid) return; coachFlush(); coach.cur = null; };\n  canvas.addEventListener('pointerup', coachUp); canvas.addEventListener('pointercancel', coachUp);\n  function coachCursor() { canvas.style.cursor = net.coach && coach.draw ? 'crosshair' : ''; $('.c-draw').classList.toggle('on', coach.draw); $('.c-draw').textContent = coach.draw ? '\u270f Draw' : '\u270b Move view'; }\n  function coachUI(on) {\n    $('.pbf-coach').hidden = !on; $('.f-walk').disabled = !!on; coachCursor(); specSync();\n    $('.n-team').closest('label').hidden = !!on; \/\/ a coach is in no team\n    if (on) { coach.draw = true; coach.cur = null; ptrs.clear(); st = null; coachCursor(); view('top'); setHud('\ud83d\udc41 <b>Spectator mode<\/b>: drag on the field to draw marks. Pick Red, Blue or both teams in the toolbar. Tap once for a dot. Switch off \u201cDraw\u201d to move the camera.'); }\n    markVis(); updateMatchUI();\n  }\n  COACH_COLS.forEach((c, i) => {\n    const b = document.createElement('button'); b.className = 'c-sw' + (i ? '' : ' sel'); b.style.background = c; b.title = 'Colour';\n    b.onclick = () => { coach.col = c; $$('.c-sw').forEach(x => x.classList.toggle('sel', x === b)); };\n    $('.c-cols').appendChild(b);\n  });\n  $('.c-draw').onclick = () => { coach.draw = !coach.draw; coachCursor(); };\n  $('.c-undo').onclick = () => { let last = null; marks.forEach(k => { if (k.own === net.pid) last = k; }); if (last) { const a = last.tm; removeMark(last.id); markSend({ t: 'dx', id: last.id, f: a }); } };\n  $('.c-clear').onclick = () => { const a = audOf(+$('.c-for').value); clearMarks(a); markSend({ t: 'dc', f: a }); };\n\n  \/* ---------- match mode: countdown (3 low beeps + 1 high), first hit wins, auto-reset ---------- *\/\n  const match = { on: false, phase: 'idle', t: 0, beep: 0, goT: 0, rdy: 1 }; \/\/ rdy: 0 = marker held low with its tip on the start zone, 1 = marker raised and ready (takes 1 s after GO)\n  \/* ---- shared clock: every guest keeps measuring how far its clock is from the host's, all match timing (countdown, GO) runs on the host's clock ---- *\/\n  net.clk = { off: 0, ok: false, s: [], ping: 0 };\n  const hostNow = () => performance.now() + (net.guest && net.clk.ok ? net.clk.off : 0); \/\/ the host's time in ms (the host itself just uses its own clock)\n  function clkStart() { \/\/ guest: a few quick pings first, then one every 2 s for the whole session\n    net.clk = { off: 0, ok: false, s: [], ping: 0 }; clearTimeout(net.clkT); let n = 0;\n    const loop = () => { if (!net.guest || !net.conn || !net.conn.open) return; net.send({ t: 'cp', c: performance.now(), p: Math.round(net.clk.ping || 0) }); net.clkT = setTimeout(loop, n++ < 6 ? 250 : 2000); };\n    loop();\n  }\n  function clkSample(m) { \/\/ guest: the host answered a ping\n    const now = performance.now(), rtt = now - (+m.c), k = net.clk;\n    if (!(rtt >= 0 && rtt < 5000) || !isFinite(+m.s)) return;\n    k.s.push({ rtt, off: +m.s + rtt \/ 2 - now }); if (k.s.length > 10) k.s.shift();\n    let b = k.s[0]; k.s.forEach(x => { if (x.rtt < b.rtt) b = x; }); \/\/ the sample with the shortest trip has the least network delay mixed in\n    k.off = k.ok ? k.off + (b.off - k.off) * .3 : b.off; k.ok = true; \/\/ smooth: the clock never jumps\n    const r = k.s.slice(-5); k.ping = r.reduce((a, x) => a + x.rtt, 0) \/ r.length;\n  }\n  function applyPing(l) { l.slice(0, 40).forEach(e => { if (Array.isArray(e)) { const p = net.players.get(e[0] | 0); if (p) p.ping = Math.max(0, e[1] | 0); } }); renderRoster(); }\n  setInterval(() => { \/\/ host: tell everybody everyone's ping\n    if (!net.host || !ncon()) return;\n    const l = [[net.pid, 0]]; net.conns.forEach((c, pid) => l.push([pid, Math.round(c.ping || 0)]));\n    net.send({ t: 'pg', l }); applyPing(l);\n  }, 2000);\n  const RDY_T = 2 \/ 3; \/\/ seconds to bring the marker up after the start signal\n  const gunDown = () => { if (!match.on || match.rdy >= 1) return 0; const t = Math.max(0, match.rdy), c1 = 1.1, c3 = c1 + 1; return 1 - (1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)); }; \/\/ 1 = fully down, eases up with a small snap at the end\n  const MATCH_HIDE_TEAM_SIGNS = true; \/\/ hide the RED \/ BLUE signs behind the start zones during a match (set to false to keep them)\n  const BEEP_LOW = 440, BEEP_HIGH = 880; \/\/ the last beep is one octave higher, like Mario Kart\n  const cEl = $('.pbf-count');\n  let ac = null, cTxt = '';\n  function unlockAudio() {\n    try { ac = ac || new (window.AudioContext || window.webkitAudioContext)(); if (ac.state === 'suspended') ac.resume(); } catch (e) {}\n  }\n  root.addEventListener('pointerdown', unlockAudio, true);\n  document.addEventListener('keydown', unlockAudio);\n  function beep(freq, dur) {\n    unlockAudio(); if (!ac) return;\n    const t = ac.currentTime, o = ac.createOscillator(), g = ac.createGain();\n    o.type = 'square'; o.frequency.value = freq;\n    g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(.12, t + .01);\n    g.gain.setValueAtTime(.12, t + dur - .06); g.gain.exponentialRampToValueAtTime(.0001, t + dur);\n    o.connect(g); g.connect(ac.destination); o.start(t); o.stop(t + dur + .02);\n  }\n  function showCount(txt, small, col) {\n    if (txt === cTxt) return;\n    cTxt = txt; cEl.textContent = txt; cEl.className = 'pbf-count' + (small ? ' sm' : ''); cEl.style.color = col || '#fff'; cEl.hidden = false;\n  }\n  function hideCount() { cTxt = ''; cEl.hidden = true; }\n  const matchLock = () => match.on && match.phase !== 'live';\n  \/* ---------- start spots: 5 per team around the start zone, numbered left to right as seen from the team's own side ---------- *\/\n  const SLOTS = 5;\n  function slotPos(t, k) { \/\/ spots 1 and 5 stand beside the zone (left \/ right), 2-4 in front of it\n    const bs = t ? bases.blue : bases.red, sg = t ? 1 : -1, rs = t ? 1 : -1, edge = W \/ 2 - .4;\n    let x, z;\n    if (k === 1 || k === 5) { x = bs.x + (k === 1 ? -1 : 1) * rs * (bs.w \/ 2 + .45); z = sg * (L \/ 2 - bs.d \/ 2); }\n    else { x = bs.x + (k - 3) * rs * cl((bs.w - .8) \/ 4, .6, 1.6); z = sg * (L \/ 2 - bs.d - .45); }\n    return { x: cl(x, -edge, edge), z, yaw: t ? 0 : Math.PI };\n  }\n  const mySlot = () => { const q = me(); return q ? q.slot | 0 : 0; };\n  function slotTaker(t, k, except) { let r = null; net.players.forEach(q => { if (!q.coach && q.team === t && (q.slot | 0) === k && q.pid !== except) r = q; }); return r; }\n  function hostSlot(pid, k) { \/\/ host: a player picks a free spot (only between rounds)\n    const q = net.players.get(pid); if (!net.host || !q || q.coach || !match.on || !matchOpen() || !(k >= 1 && k <= SLOTS) || slotTaker(q.team, k, pid)) return false;\n    q.slot = k; q.last = k; return true;\n  }\n  function hostClearSlots() { net.players.forEach(q => { q.slot = 0; }); }\n  function hostAutoSlots() { \/\/ the choosing time is over: whoever has no spot gets a free one (their last one if it is still free)\n    if (!net.host) return;\n    [0, 1].forEach(t => {\n      const l = Array.from(net.players.values()).filter(q => !q.coach && q.team === t).sort((a, b) => a.pid - b.pid);\n      l.forEach(q => { if (q.slot && slotTaker(t, q.slot, q.pid)) q.slot = 0; }); \/\/ safety: no double spots\n      l.forEach(q => { if (!q.slot && q.last && !slotTaker(t, q.last, q.pid)) q.slot = q.last; });\n      l.forEach(q => { if (!q.slot) for (let k = 1; k <= SLOTS; k++) if (!slotTaker(t, k, q.pid)) { q.slot = k; break; } });\n      l.forEach(q => { q.last = q.slot; });\n    });\n  }\n  function placeAtStart() { \/\/ every player gets their own spot along their team's start zone\n    const t = net.team, bs = t ? bases.blue : bases.red, sg = t ? 1 : -1;\n    const mates = Array.from(net.players.values()).filter(p => p.team === t && !p.coach).sort((a, b) => a.pid - b.pid);\n    const n = Math.max(1, mates.length), i = Math.max(0, mates.findIndex(p => p.pid === net.pid));\n    \/\/ players stand just OUTSIDE the zone, touching it with a foot and the tip of the marker,\n    \/\/ all in one row in front of it, side by side with a small offset\n    const edge = W \/ 2 - .4, step = Math.min(1.2, Math.max(.45, (bs.w - .8) \/ n)), k = mySlot();\n    if (k >= 1 && k <= 5) { const sp = slotPos(t, k); fp.x = sp.x; fp.z = sp.z; } \/\/ the start spot you picked (or were given)\n    else {\n      fp.x = cl(bs.x + (i - (n - 1) \/ 2) * step, -edge, edge);\n      fp.z = sg * (L \/ 2 - bs.d - .45);\n    }\n    fp.prone = false; fp.gy = 0; fp.yaw = t ? 0 : Math.PI; fp.pitch = 0; fp.y = 0; fp.vy = 0; fp.slide = null; Object.keys(fp.ammo).forEach(k => { fp.ammo[k] = WEAPONS[k].mag; }); fp.reloadT = 0;\n  }\n  function updateMatchUI() {\n    const host = net.host && ncon(), sel = $('.n-mode');\n    sel.disabled = net.guest;\n    $('.n-team').disabled = !matchOpen() || net.coach || (match.on && isFlag());\n    $('.n-start').hidden = !(host && \/^(match|buzzer|flag|flag7)$\/.test(sel.value) && !match.on);\n    $('.n-stop').hidden = !(host && match.on);\n    $$('[data-wpn]').forEach(b => { b.disabled = !canSwitchWeapon(); }); \/\/ weapon buttons are greyed out while a game runs\n    \/\/ while a match is running the field cannot be edited and Practice is unavailable\n    $$('[data-sec=\"bunk\"],[data-sec=\"zones\"],[data-sec=\"field\"]').forEach(d => { d.inert = match.on; d.classList.toggle('m-off', match.on); });\n    { const off = match.on || net.guest, d = $('.p-maps'); d.inert = off; d.classList.toggle('m-off', off); } \/\/ maps: not during a match, and only the host loads maps online\n    $('[data-mode=\"quiz\"]').disabled = match.on;\n    if (match.on) { tool = null; if (mode === 'quiz') setMode('play'); }\n  }\n  function zoneDist(x, z, bs, sg) { \/\/ distance from a point to a start zone rectangle (0 = inside)\n    const za = sg * (L \/ 2 - bs.d), zb = sg * (L \/ 2);\n    const dx = Math.max(bs.x - bs.w \/ 2 - x, 0, x - (bs.x + bs.w \/ 2)), dz = Math.max(Math.min(za, zb) - z, 0, z - Math.max(za, zb));\n    return Math.hypot(dx, dz);\n  }\n  function buzzerCheck() { \/\/ Buzzer mode: a player who touches the ENEMY start zone wins the round for their team (host decides)\n    if (!net.host || !match.on || match.phase !== 'live' || $('.n-mode').value !== 'buzzer') return;\n    let w = -1;\n    net.players.forEach(p => {\n      if (w >= 0 || p.out) return;\n      const own = p.pid === net.pid;\n      if (!(own ? fp.on : p.r.vis)) return;\n      const x = own ? fp.x : p.r.x, z = own ? fp.z : p.r.z, t = p.team;\n      if (zoneDist(x, z, t ? bases.red : bases.blue, t ? -1 : 1) < .52) w = t; \/\/ walking into a zone stops at 0.4 m, so < 0.52 = touching\n    });\n    if (w >= 0) { net.send({ t: 'rend', w, b: 1 }); roundEnd(w, true); }\n  }\n  \/* ---------- Flag mode (NSL Sports rules) ---------- *\/\n  \/\/ Attackers hold the flag and get 3 tries per round; the roles swap after every round. The host decides everything.\n  const FLAG_ROUNDS = 4, FLAG_TRIES = 3, FLAG_CLOCK = 15 * 60, FLAG_BUZZ_ON = 30, FLAG_BUZZ_WIN = 3, FLAG_PAUSE = 10, ZONE_F = [.25, .5, .75];\n  const fl = { rn: 1, tn: 1, att: 0, pts: [0, 0], zs: 0, clock: 0, tt: 0, st: 0, c: -1, x: 0, z: 0, maxZone: 0, bz: -1, banned: new Set(), done: false, hk: '', bk: 0, bonus: false, hid: -1 }; \/\/ st: 0 = carried, 1 = on the floor\n  const isFlag = () => \/^flag\/.test($('.n-mode').value);\n  const isFlag7 = () => $('.n-mode').value === 'flag7';\n  const mmss = t => Math.floor(t \/ 60) + ':' + ('0' + Math.floor(t % 60)).slice(-2);\n  const flagMat = new THREE.MeshStandardMaterial({ color: 0xffd400, emissive: 0xffa800, emissiveIntensity: .35, side: THREE.DoubleSide, roughness: .8 });\n  const flagM = new THREE.Mesh(new THREE.PlaneGeometry(.4, .4), flagMat); flagM.visible = false; scene.add(flagM); \/\/ bandana-sized\n  const flagFP = new THREE.Mesh(new THREE.PlaneGeometry(.3, .3), flagMat); flagFP.position.set(-.2, -.16, -.45); flagFP.rotation.set(-.2, .5, .1); flagFP.visible = false; gunRig.add(flagFP);\n  const flagPos = p => p.pid === net.pid ? [fp.x, fp.z] : [p.r.x, p.r.z];\n  const flagLive = p => !p.out && (p.pid === net.pid ? fp.on : p.r.vis);\n  function flagInit() { Object.assign(fl, { rn: 1, tn: 1, att: 0, pts: [0, 0], zs: 0, clock: 0, done: false, bonus: false, hid: -1 }); net.score.t = [0, 0]; }\n  function flagReset() { \/\/ a new try: the attacking team holds the flag\n    fl.tt = 0; fl.maxZone = 0; fl.bz = -1; fl.banned = new Set(); fl.st = 0; fl.hk = '';\n    const a = Array.from(net.players.values()).filter(p => p.team === fl.att && !p.coach).sort((x, y) => x.pid - y.pid)[0];\n    fl.c = a ? a.pid : -1;\n    if (fl.bonus) { \/\/ v7.0 Bonus Try: 1 attacker (the one who hung the flag) against 1 defender, everybody else sits out\n      const A = Array.from(net.players.values()).filter(p => p.team === fl.att && !p.coach), D = Array.from(net.players.values()).filter(p => p.team !== fl.att && !p.coach).sort((x, y) => x.pid - y.pid);\n      const atk = A.find(p => p.pid === fl.hid) || A.sort((x, y) => x.pid - y.pid)[0], def = D[0];\n      net.players.forEach(p => { if (p !== atk && p !== def) { p.out = true; net.send({ t: 'o', v: p.pid }); } });\n      fl.c = atk ? atk.pid : -1;\n    }\n  }\n  function flagEndUI(msg) {\n    if (!match.on) return;\n    match.phase = 'over'; match.t = 0; fp.firing = false; showCount(msg, true, '#fff'); badge(); updateMatchUI();\n  }\n  function flagEnd(why) { \/\/ host: a try is over\n    if (why === 'hang' && fl.c >= 0) stat(fl.c).h++;\n    if (why === 'snatch' && fl.snp >= 0) stat(fl.snp).n++;\n    const att = fl.att, a = TN[att], v7 = isFlag7();\n    let gain, msg, roundOver;\n    if (fl.bonus) { \/\/ v7.0 Bonus Try: +2 for a flag hang within 30 s, then the roles swap\n      gain = why === 'hang' ? 2 : 0; roundOver = true; fl.bonus = false;\n      msg = gain ? '\u2b50 Bonus try: ' + a + ' hangs the flag, +2 points' : '\u2b50 Bonus try: no hang, no points';\n    } else if (why === 'hang') {\n      gain = 13 - fl.tn - fl.zs; msg = '\ud83d\udea9 Flag hang! ' + a + ' scores ' + (13 - fl.tn) + ' points'; \/\/ 12 \/ 11 \/ 10, replaces earlier zone points of this round\n      roundOver = !v7; \/\/ v7.0: a flag hang is followed by the 1v1 Bonus Try\n      if (v7) { fl.bonus = true; fl.hid = fl.c; msg += ' \u00b7 bonus try next'; }\n    } else {\n      gain = fl.maxZone; fl.zs += gain;\n      msg = { snatch: '\u270b Snatch!', abuzzer: '\ud83d\udd14 Buzzer: the attackers are out.', dbuzzer: '\ud83d\udd14 Buzzer: the defenders are out.', time: '\u23f1 Time is up!' }[why] + ' ' + (gain ? a + ' +' + gain + ' (zone ' + fl.maxZone + ')' : 'No points.');\n      roundOver = why === 'snatch' || why === 'time' || fl.tn >= FLAG_TRIES;\n    }\n    fl.pts[att] += gain;\n    if (roundOver) { fl.rn++; fl.att = 1 - fl.att; fl.tn = 1; fl.zs = 0; } else if (!fl.bonus) fl.tn++;\n    fl.done = why === 'time' || fl.rn > FLAG_ROUNDS;\n    if (v7 && Math.abs(fl.pts[0] - fl.pts[1]) > 34) { fl.done = true; msg += ' \u00b7 Mercy rule!'; } \/\/ v7.0: a lead of more than 34 points wins the match\n    if (fl.done) fl.bonus = false;\n    net.score.t = fl.pts.slice();\n    if (fl.done) { const d = fl.pts[0] - fl.pts[1]; msg += '  \ud83c\udfc1 Match over: ' + (d ? TN[d > 0 ? 0 : 1] + ' wins ' + Math.max(fl.pts[0], fl.pts[1]) + '\u2013' + Math.min(fl.pts[0], fl.pts[1]) : 'draw ' + fl.pts[0] + '\u2013' + fl.pts[1]); }\n    net.send({ t: 'fend', m: msg, d: fl.done ? 1 : 0 });\n    flagEndUI(msg);\n  }\n  function flagAction(pid) { \/\/ G: hand the flag to a teammate within 2 m, or drop it\n    if (!net.host || !isFlag() || !match.on || match.phase !== 'live' || fl.st !== 0 || fl.c !== pid) return;\n    const c = net.players.get(pid); if (!c || !flagLive(c)) return;\n    const q = flagPos(c); let best = null, bd = 2;\n    net.players.forEach(p => {\n      if (p === c || p.team !== c.team || !flagLive(p) || fl.banned.has(p.pid)) return;\n      const r = flagPos(p), d = Math.hypot(r[0] - q[0], r[1] - q[1]); if (d < bd) { bd = d; best = p; }\n    });\n    if (best) fl.c = best.pid; else { fl.st = 1; fl.x = q[0]; fl.z = q[1]; fl.c = -1; fl.banned.add(pid); }\n  }\n  function flagKey() { if (!isFlag() || !match.on || match.phase !== 'live') return; if (net.host) flagAction(net.pid); else net.send({ t: 'g' }); }\n  function flagCheck(dt) {\n    if (!net.host || !match.on || match.phase !== 'live' || !isFlag()) return;\n    fl.tt += dt; fl.clock += dt;\n    const att = fl.att, P = Array.from(net.players.values()), cnt = [0, 0], al = [0, 0];\n    P.forEach(p => { if (!p.coach) cnt[p.team]++; if (!p.out) al[p.team]++; });\n    if (!cnt[0] || !cnt[1]) { stopMatch(true); nstat('Match stopped: both teams need at least one player.'); return; }\n    const sgD = att ? -1 : 1, bsD = att ? bases.red : bases.blue, sgA = -sgD, bsA = att ? bases.blue : bases.red;\n    \/\/ a hit (or missing) carrier drops the flag where he stood; the one who dropped it may not pick it up again\n    if (fl.st === 0) {\n      const c = net.players.get(fl.c); if (c) { const q = flagPos(c); fl.x = q[0]; fl.z = q[1]; }\n      if (!c || !flagLive(c)) { if (c) fl.banned.add(c.pid); fl.st = 1; fl.c = -1; }\n    }\n    if (fl.st === 1) P.forEach(p => {\n      if (fl.st !== 1 || p.team !== att || !flagLive(p) || fl.banned.has(p.pid)) return;\n      const q = flagPos(p); if (Math.hypot(q[0] - fl.x, q[1] - fl.z) < .8) { fl.st = 0; fl.c = p.pid; }\n    });\n    \/\/ zone points: the whole body of the carrier (0.4 m) has to cross the line; a flag on the floor counts where it lies\n    const S = L - bases.red.d - bases.blue.d, pr = (fl.z - (att ? 1 : -1) * (L \/ 2 - bsA.d)) * (att ? -1 : 1), fz = fl.st === 0 ? pr - .4 : pr;\n    let zn = 0; ZONE_F.forEach(f => { if (fz >= f * S) zn++; }); fl.maxZone = Math.max(fl.maxZone, zn);\n    \/\/ flag hang: the carrier touches the defenders' start zone\n    const car = fl.st === 0 ? net.players.get(fl.c) : null;\n    if (car && flagLive(car)) { const q = flagPos(car); if (zoneDist(q[0], q[1], bsD, sgD) < .52) { flagEnd('hang'); return; } }\n    \/\/ snatch: a defender touches the flag, or the attackers' start zone\n    for (const p of P) {\n      if (p.team === att || !flagLive(p)) continue;\n      const q = flagPos(p);\n      const wall = !(isFlag7() && fl.bz >= 0); \/\/ v7.0: no more wall snatches once the buzzer has sounded\n      if (Math.hypot(q[0] - fl.x, q[1] - fl.z) < .9 || (wall && zoneDist(q[0], q[1], bsA, sgA) < .52)) { fl.snp = p.pid; flagEnd('snatch'); return; }\n    }\n    \/\/ buzzer: active from 30 s; when a whole team is out the other team gets 3 s to finish\n    if (fl.bonus) { if (!al[att] || fl.tt >= 30) { flagEnd('bfail'); return; } } \/\/ Bonus Try: the attacker is hit, or 30 s are over\n    else if (fl.bz < 0) { if (fl.tt >= FLAG_BUZZ_ON && (!al[att] || !al[1 - att])) fl.bz = FLAG_BUZZ_WIN; }\n    else if ((fl.bz -= dt) <= 0) { flagEnd(al[att] ? 'dbuzzer' : 'abuzzer'); return; }\n    if (fl.clock >= FLAG_CLOCK) flagEnd('time');\n  }\n  function updateFlag() { \/\/ draw the flag (in the carrier's hand, or on the floor)\n    flagM.visible = flagFP.visible = false;\n    if (!(isFlag() && match.on && ncon())) return;\n    if (fl.st === 0) {\n      if (fl.c === net.pid) { flagFP.visible = fp.on; flagFP.position.x = -.2 * fp.handS; return; }\n      const p = net.players.get(fl.c); if (!p) return;\n      const g = p.grp, vis = !!(g && g.visible), x = vis ? g.position.x : p.r.x, z = vis ? g.position.z : p.r.z, yw = vis ? g.rotation.y : p.r.yaw;\n      flagM.position.set(x - Math.cos(yw) * .3 - Math.sin(yw) * .4, (p.r.prone ? .5 : p.r.crouch ? .8 : 1.15) + (vis ? g.position.y : 0), z + Math.sin(yw) * .3 - Math.cos(yw) * .4);\n      flagM.rotation.set(0, yw, 0); flagM.visible = true;\n    } else { flagM.position.set(fl.x, .08, fl.z); flagM.rotation.set(-Math.PI \/ 2, 0, 0); flagM.visible = true; }\n  }\n  function flagUI(dt) {\n    if (!(isFlag() && match.on && fp.on)) return;\n    fl.bk += dt; if (fl.bk > .25) { fl.bk = 0; badge(); }\n    const k = fl.rn + ':' + fl.tn + ':' + fl.att + ':' + (fl.bonus ? 1 : 0);\n    if (fl.hk !== k) {\n      fl.hk = k;\n      setHud(fl.bonus ? (me() && me().out ? '\u2b50 <b>Bonus try<\/b>: you sit out this 1v1.' : net.team === fl.att ? '\u2b50 <b>BONUS TRY (1v1)<\/b>: hang the flag on the enemy start zone within 30 s for +2 points.' : '\u2b50 <b>BONUS TRY (1v1)<\/b>: stop the attacker: shoot them or touch the flag.') : net.team === fl.att ? '\ud83d\udea9 <b>ATTACK<\/b>: carry the flag to the enemy start zone and touch it. Crossing the yellow lines scores 1-3 points. <b>G<\/b> = hand over \/ drop the flag.' : '\ud83d\udee1 <b>DEFEND<\/b>: shoot the attackers, and touch the flag (or their start zone) to snatch it.');\n    }\n  }\n\n  const pickT = () => isFlag() ? 5 : 6; \/\/ seconds to choose a start spot \/ weapon before the countdown\n  function startRound(reset, at) { \/\/ at = the host's clock time (ms) at which this round's countdown started\n    if (reset) { net.score = { me: 0, t: [0, 0] }; clearMarks(); stats.clear(); if (net.host) { hostClearSlots(); sendRoster(); } }\n    net.players.forEach(p => { p.out = !!p.coach; });\n    if (isFlag() && net.host) { if (reset) flagInit(); flagReset(); }\n    match.on = true; match.phase = 'count'; match.t = -pickT(); match.beep = 0; match.goT = 0; match.rdy = Math.min(match.rdy, 1);\n    match.t0 = isFinite(at) ? at : hostNow(); match.goAt = match.t0 + (pickT() + 2) * 1000; \/\/ everything is timed on the host's clock\n    if (net.coach) { \/* coaches only watch *\/ } else if (!fp.on) enterWalk(); else { fp.keys = {}; placeAtStart(); }\n    fp.firing = false; fp.recoil = 0; clearShots(); hideCount();\n    badge(); updateMatchUI(); updateLabels();\n  }\n  function roundEnd(w, bz) { \/\/ w = winning team (0 red, 1 blue) or -1 for a draw; bz = won by touching the enemy start zone (Buzzer)\n    if (!match.on || match.phase === 'over') return;\n    match.phase = 'over'; match.t = 0; fp.firing = false;\n    if (w >= 0) net.score.t[w]++;\n    if (net.host) { hostClearSlots(); sendRoster(); } \/\/ new round, new choice of start spots\n    badge(); updateMatchUI();\n    showCount(w < 0 ? 'Draw' : w === net.team ? (bz ? '\ud83d\udd14 Buzzer! Your team wins the round!' : '\ud83c\udfc6 Your team wins the round!') : (bz ? '\ud83d\udd14 Buzzer! The other team wins the round' : '\ud83d\udca5 The other team wins the round'), true, w < 0 ? '#fff' : w === net.team ? '#2ecc71' : '#ff8e91');\n  }\n  function stopMatch(send) {\n    if (!match.on) return;\n    match.on = false; match.phase = 'idle'; match.rdy = 1; hideCount(); fl.hk = '';\n    if (send) net.send({ t: 'stop' });\n    updateMatchUI(); updateLabels(); badge();\n  }\n  function updateMatch(dt) {\n    if (!match.on) return;\n    if (match.phase === 'count') match.rdy = Math.max(0, match.rdy - dt * 3); \/\/ the marker goes down to the start zone during the countdown\n    else if (match.phase === 'live') match.rdy = match.goAt ? cl((hostNow() - match.goAt) \/ 1000 \/ RDY_T, 0, 1) : Math.min(1, match.rdy + dt \/ RDY_T); \/\/ after GO it takes a second to bring it up\n    if (match.phase === 'count') {\n      match.t = (hostNow() - match.t0) \/ 1000 - pickT(); \/\/ real time on the host's clock: a slow frame rate or a hidden tab no longer slows the countdown\n      if (match.t < 0) { showCount(isFlag() ? '\ud83d\udea9 Round ' + fl.rn + ' \u00b7 ' + (fl.bonus ? '\u2b50 Bonus try (1v1)' : 'Try ' + fl.tn) + ' \u00b7 ' + TN[fl.att] + ' attacks' : 'Get ready\u2026 ' + Math.ceil(-match.t), true); return; }\n      while (match.beep < 3 && match.t >= match.beep) { \/\/ one call per second: Feet, Weapon, Go!\n        const i = match.beep++;\n        if (i === 0) { updateMatchUI(); if (net.host) { if (!isFlag()) hostBalance(); hostAutoSlots(); sendRoster(); renderRoster(); net.send({ t: 'place' }); if (fp.on && !net.coach) placeAtStart(); } } \/\/ the countdown started: weapon \/ team \/ role are locked; last chance to balance the teams\n        if (i < 2) { beep(BEEP_LOW, .22); showCount(['Feet', 'Weapons'][i]); }\n        else { beep(BEEP_HIGH, .6); showCount('Go!', false, '#2ecc71'); match.phase = 'live'; match.goT = .8; }\n      }\n    } else if (match.phase === 'live') {\n      if (match.goT > 0 && (match.goT -= dt) <= 0) hideCount();\n      buzzerCheck(); flagCheck(dt);\n    } else if (match.phase === 'over') {\n      match.t += dt;\n      if (match.t >= (isFlag() ? (fl.done ? 6 : FLAG_PAUSE) : 3) && net.host) { \/\/ the host starts the next round\n        if (isFlag() && fl.done) { stopMatch(true); nstat('Match finished.'); return; }\n        if (!isFlag()) hostBalance(); \/\/ uneven teams (for example after team switches) are balanced before the next round\n        const c = [0, 0]; net.players.forEach(p => { if (!p.coach) c[p.team]++; });\n        if (!ncon() || !c[0] || !c[1]) { stopMatch(true); nstat('Match stopped: both teams need at least one player.'); }\n        else { const at = hostNow(); net.send({ t: 'round', at }); startRound(false, at); }\n      }\n    }\n  }\n  function specSync() { $('.f-spec').classList.toggle('on', $('.n-coach').checked); }\n  $('.f-spec').onclick = () => { const c = $('.n-coach'); c.checked = !c.checked; c.onchange({ target: c }); specSync(); if (!net.host && !net.guest) setHud(c.checked ? '\ud83d\udc41 Spectator mode is on: it applies when you host or join a game.' : 'Spectator mode is off.'); };\n  $('.n-coach').onchange = e => {\n    const co = e.target.checked;\n    if (net.host) {\n      if (!matchOpen()) { e.target.checked = net.coach; nstat('You cannot switch role while a round is running.'); setHud('\ud83d\udd12 You can switch between spectator and player between rounds.'); setTimeout(hint, 2500); return; }\n      if (co && fp.on) exitWalk(true);\n      if (!hostSetRole(net.pid, co)) { e.target.checked = net.coach; nstat('You cannot switch role now (the team is full).'); return; }\n      sendRoster(); replaceAll(); renderRoster(); badge(); updateMatchUI(); hostStatus();\n      if (!co && match.on && !fp.on) enterWalk(); \/\/ became a player during the pause: join the next round\n    } else if (net.guest && ncon()) { e.target.checked = net.coach; net.send({ t: 'role', c: co ? 1 : 0 }); } \/\/ the host answers; the box follows\n  };\n  $('.n-mode').onchange = e => {\n    if (!net.host) return; pubKick();\n    net.send({ t: 'mode', m: e.target.value });\n    stopMatch(true); \/\/ changing the mode ends a running match\n    updateLabels();\n    updateMatchUI();\n  };\n  $('.n-start').onclick = () => {\n    if (!net.host || !ncon()) return;\n    hostBalance(); \/\/ balance the teams before the match starts\n    const c = [0, 0]; net.players.forEach(p => { if (!p.coach) c[p.team]++; });\n    if (!c[0] || !c[1]) { nstat('You need at least one player on each team to start a match.'); return; }\n    { const at = hostNow(); net.send({ t: 'round', r: 1, at }); startRound(true, at); }\n  };\n  $('.n-stop').onclick = () => stopMatch(true);\n\n  \/* ---------- fullscreen ---------- *\/\n  const fsOn = () => document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains('pseudo-fs');\n  function fsUpdate() { $('.f-fs').textContent = fsOn() ? '\u26f6 Exit fullscreen' : '\u26f6 Fullscreen'; resize(); }\n  function toggleFs() {\n    if (fsOn()) {\n      if (stage.classList.contains('pseudo-fs')) stage.classList.remove('pseudo-fs');\n      else if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen();\n      fsUpdate(); return;\n    }\n    const req = stage.requestFullscreen || stage.webkitRequestFullscreen;\n    if (req) {\n      try { const r = req.call(stage); if (r && r.catch) r.catch(() => { stage.classList.add('pseudo-fs'); fsUpdate(); }); } catch (e) { stage.classList.add('pseudo-fs'); fsUpdate(); }\n    } else { stage.classList.add('pseudo-fs'); fsUpdate(); } \/\/ e.g. iPhone: fill the screen with CSS instead\n  }\n  $('.f-fs').onclick = toggleFs;\n  document.addEventListener('fullscreenchange', fsUpdate);\n  document.addEventListener('webkitfullscreenchange', fsUpdate);\n  document.addEventListener('keydown', e => { if (e.key === 'Escape' && stage.classList.contains('pseudo-fs')) { stage.classList.remove('pseudo-fs'); fsUpdate(); } });\n\n  \/* ---------- maps: premade maps + fields saved in this browser ---------- *\/\n  \/\/ ADD NEW PREMADE MAPS HERE: copy the field code (\"Copy field (JSON)\") into a new entry { name, data: <code> }.\n  const DEFAULT_MAP = 'DSL 2026'; \/\/ the premade map (by name) that loads on a first visit, whatever its place in the list\n  const PREMADE = [\n    { name: 'DSL 2026', data: {\"v\":1,\"W\":30,\"L\":50,\"fc\":\"#2f3b4a\",\"fb\":50,\"ft\":1,\"tx\":\"concrete\",\"fts\":4,\"ind\":1,\"lg\":130,\"lk\":1,\"zn\":{\"red\":[4,1,0,1],\"blue\":[4,1,0,1]},\"b\":[{\"type\":\"roof\",\"name\":\"Cobra\",\"x\":6.71,\"z\":12.91,\"rot\":0,\"s\":[1.2143,2.0444,1.1162],\"c\":\"tex:grain\"},{\"type\":\"cross\",\"name\":\"Plexi\",\"x\":0.03,\"z\":1.86,\"rot\":0,\"s\":[1.2885,1,1.2308],\"c\":\"tex:grain\"},{\"type\":\"cross\",\"name\":\"Plexi\",\"x\":0.04,\"z\":-1.86,\"rot\":0,\"s\":[1.3077,1,1.3462],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Cobra\",\"x\":6.69,\"z\":-12.92,\"rot\":0,\"s\":[1.2143,2.0444,1.1162],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Tank\",\"x\":-11.7,\"z\":17.15,\"rot\":0,\"s\":[1.2143,2.0444,1.1932],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Tank\",\"x\":-11.7,\"z\":-17.2,\"rot\":0,\"s\":[1.2143,2.0444,1.1932],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Roof\",\"x\":-11.69,\"z\":0,\"rot\":0,\"s\":[1.2143,2.0444,1.2317],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Ace\",\"x\":0,\"z\":-8.61,\"rot\":0,\"s\":[0.9107,2.0444,1.2317],\"c\":\"tex:grain\"},{\"type\":\"staple\",\"name\":\"Staple\",\"x\":-5.08,\"z\":-6.33,\"rot\":-2.356,\"s\":[1.6346,1,1.4286],\"c\":\"tex:grain\"},{\"type\":\"square\",\"name\":\"King\",\"x\":-10.55,\"z\":-9.57,\"rot\":0,\"s\":[2.4167,2.1667,2.4167],\"c\":\"tex:grain\"},{\"type\":\"square\",\"name\":\"King\",\"x\":-10.56,\"z\":9.48,\"rot\":3.142,\"s\":[2.4167,2.1667,2.4167],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Home\",\"x\":0.01,\"z\":16.44,\"rot\":3.142,\"s\":[0.5893,2.0444,2.0785],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Home\",\"x\":0.02,\"z\":-16.46,\"rot\":0,\"s\":[0.5893,2.0444,2.04],\"c\":\"tex:grain\"},{\"type\":\"square\",\"name\":\"Queen\",\"x\":5.06,\"z\":-5.02,\"rot\":-0.785,\"s\":[2.4167,2.1667,2.4167],\"c\":\"tex:grain\"},{\"type\":\"square\",\"name\":\"Queen\",\"x\":5.08,\"z\":5.02,\"rot\":2.356,\"s\":[2.4167,2.1667,2.4167],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Ace\",\"x\":0.03,\"z\":8.57,\"rot\":0,\"s\":[0.9107,2.0444,1.2317],\"c\":\"tex:grain\"},{\"type\":\"staple\",\"name\":\"Staple\",\"x\":-5.09,\"z\":6.38,\"rot\":-0.785,\"s\":[1.6346,1,1.4286],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"50\",\"x\":-6.66,\"z\":-0.05,\"rot\":-1.571,\"s\":[0.9107,2.0444,1.2317],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Danger\",\"x\":12.63,\"z\":-0.03,\"rot\":-1.571,\"s\":[0.9107,2.0444,1.2702],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Pit\",\"x\":11.74,\"z\":-19.34,\"rot\":0.785,\"s\":[0.6071,2.0444,2.04],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Snake\",\"x\":11.88,\"z\":-10.05,\"rot\":-2.356,\"s\":[1.1942,1,1.1942],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Snake\",\"x\":10.77,\"z\":-8.06,\"rot\":-2.356,\"s\":[1.1942,1,1.1942],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Snake\",\"x\":10.75,\"z\":7.99,\"rot\":-0.785,\"s\":[1.1942,1,1.1942],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Snake\",\"x\":11.9,\"z\":10.01,\"rot\":-0.785,\"s\":[1.1942,1,1.1942],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Sniper\",\"x\":9.1,\"z\":2.13,\"rot\":-1.571,\"s\":[1,1,1],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Sniper\",\"x\":9.09,\"z\":-2.14,\"rot\":-1.571,\"s\":[1,1,1],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Pit\",\"x\":11.68,\"z\":19.29,\"rot\":2.356,\"s\":[0.6071,2.0444,2.0785],\"c\":\"tex:grain\"},{\"type\":\"plexi\",\"name\":\"Plexi\",\"x\":0.03,\"z\":0.06,\"rot\":0,\"s\":[3.0909,1.375,2.5],\"c\":\"tex:grain\"}]} }\n    ,{ name: 'NSL Sports Season 2026', data: {\"v\":1,\"W\":30,\"L\":52.3,\"fc\":\"#5aa84a\",\"fb\":50,\"ft\":1,\"fts\":1,\"ind\":1,\"lg\":130,\"lk\":1,\"zn\":{\"red\":[5,0.5,0,1],\"blue\":[5,0.5,0,1]},\"b\":[{\"type\":\"arrow\",\"name\":\"Bunker 4\",\"x\":-2.47,\"z\":-17.09,\"rot\":-0.785,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"staple\",\"name\":\"Bunker 8\",\"x\":10.37,\"z\":-12.79,\"rot\":-4.189,\"s\":[1.5769,1,1.2535],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"roof\",\"name\":\"Bunker 2\",\"x\":0.22,\"z\":-10.96,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 7\",\"x\":-5.99,\"z\":-8.68,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 9\",\"x\":-12.01,\"z\":-6.23,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 10\",\"x\":-12.03,\"z\":-18.42,\"rot\":-1.571,\"s\":[0.4643,2.7111,1.5011],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 11\",\"x\":12.05,\"z\":-18.51,\"rot\":-3.142,\"s\":[0.4643,2.7111,1.5011],\"c\":\"tex:grain\"},{\"type\":\"square\",\"name\":\"Bunker 12\",\"x\":0.12,\"z\":-5.37,\"rot\":0,\"s\":[1.7083,1.125,1.75],\"c\":\"tex:grain\"},{\"type\":\"cross\",\"name\":\"Bunker 13\",\"x\":7.04,\"z\":-7.97,\"rot\":0,\"s\":[1.5577,1,1.5577],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 14\",\"x\":11.66,\"z\":-4.89,\"rot\":0,\"s\":[1.3595,1,1.3595],\"c\":\"tex:grain\",\"th\":0.35},{\"type\":\"arrow\",\"name\":\"Bunker 1\",\"x\":-6.55,\"z\":-21.42,\"rot\":-0.785,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 3\",\"x\":-5.51,\"z\":-14.7,\"rot\":-0.785,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 5\",\"x\":2.85,\"z\":-17.27,\"rot\":0.786,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 6\",\"x\":7.18,\"z\":-21.4,\"rot\":0.786,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"square\",\"name\":\"Bunker 15\",\"x\":13.2,\"z\":0.01,\"rot\":0,\"s\":[0.375,1,2.0417],\"c\":\"tex:grain\"},{\"type\":\"plexi\",\"name\":\"Bunker 16\",\"x\":11.98,\"z\":-0.01,\"rot\":0,\"s\":[1.9545,1.75,4.375],\"c\":\"tex:grain\"},{\"type\":\"cross\",\"name\":\"Bunker 17\",\"x\":7.06,\"z\":6.92,\"rot\":0,\"s\":[1.5577,1,1.5577],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"staple\",\"name\":\"Bunker 18\",\"x\":10.55,\"z\":11.81,\"rot\":-5.236,\"s\":[1.5769,1,1.2535],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 19\",\"x\":2.98,\"z\":17.43,\"rot\":2.357,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"roof\",\"name\":\"Bunker 20\",\"x\":12.19,\"z\":17.49,\"rot\":-3.142,\"s\":[0.4643,2.7111,1.5011],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 21\",\"x\":-12,\"z\":17.43,\"rot\":-4.713,\"s\":[0.4643,2.7111,1.5011],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 22\",\"x\":-12,\"z\":5.87,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 23\",\"x\":-5.61,\"z\":8.29,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 24\",\"x\":0.23,\"z\":10.66,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Bunker 26\",\"x\":11.71,\"z\":3.73,\"rot\":-3.142,\"s\":[1.3595,1,1.3595],\"c\":\"tex:grain\",\"th\":0.35},{\"type\":\"square\",\"name\":\"Bunker 25\",\"x\":0.32,\"z\":4.54,\"rot\":0,\"s\":[1.7083,1.125,1.75],\"c\":\"tex:grain\"},{\"type\":\"arrow\",\"name\":\"Bunker 27\",\"x\":7.1,\"z\":21.47,\"rot\":2.357,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 28\",\"x\":-2.26,\"z\":17.52,\"rot\":3.927,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 29\",\"x\":-6.63,\"z\":21.17,\"rot\":3.927,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"arrow\",\"name\":\"Bunker 30\",\"x\":-5.3,\"z\":14.5,\"rot\":3.927,\"s\":[1.2493,1,1.2493],\"c\":\"tex:grain\",\"th\":0.4},{\"type\":\"roof\",\"name\":\"Bunker 31\",\"x\":-1.51,\"z\":0.04,\"rot\":-3.142,\"s\":[0.5357,2.7111,1.4241],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 32\",\"x\":2.56,\"z\":0.02,\"rot\":-3.142,\"s\":[0.5357,2.7111,1.4241],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 33\",\"x\":0.54,\"z\":-0.03,\"rot\":-3.142,\"s\":[1.0179,1.1556,0.9238],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 34\",\"x\":-10.01,\"z\":0.19,\"rot\":-1.571,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"roof\",\"name\":\"Bunker 35\",\"x\":6.98,\"z\":0.02,\"rot\":0,\"s\":[0.8214,2.7111,1.5396],\"c\":\"tex:grain\"},{\"type\":\"window\",\"name\":\"Bunker 36\",\"x\":-5.49,\"z\":0.04,\"rot\":0,\"s\":[0.8036,2.7111,1.5385],\"c\":\"tex:grain\",\"wh\":0.65,\"wy\":1.35,\"ww\":1.55},{\"type\":\"window\",\"name\":\"Bunker 37\",\"x\":-14,\"z\":0.08,\"rot\":0,\"s\":[0.8036,2.7111,1.5385],\"c\":\"tex:grain\",\"wh\":0.65,\"wy\":1.35,\"ww\":1.55}]} }\n  ];\n  const SAVEKEY = 'pbf_saved_v1';\n  const mapInfo = o => (o.b ? o.b.length : 0) + ' bunkers \u00b7 ' + (+o.W || 30) + ' \u00d7 ' + (+o.L || 40) + ' m';\n  function mapsOk() {\n    if (net.guest) { setHud('In an online game only the host can load a map.'); return false; }\n    if (match.on) { setHud('You cannot load a map during a match.'); return false; }\n    return true;\n  }\n  function mapLoad(o, name) {\n    if (!mapsOk()) return;\n    if (bunkers.length && !confirm('Load \"' + name + '\"? The current field will be replaced.\\n(Save it under \"My saved fields\" first if you want to keep it.)')) return;\n    exitWalk(); stopQuiz();\n    if (load(JSON.stringify(o))) { persist(); setHud('\ud83d\uddfa Loaded <b>' + esc(name) + '<\/b>.'); setTimeout(hint, 2500); } else setHud('That map could not be loaded.');\n  }\n  function savedGet() { try { const a = JSON.parse(localStorage.getItem(SAVEKEY) || '[]'); return Array.isArray(a) ? a.filter(e => e && typeof e.n === 'string' && typeof e.d === 'string') : []; } catch (e) { return []; } }\n  function savedPut(a) { try { localStorage.setItem(SAVEKEY, JSON.stringify(a)); return true; } catch (e) { setHud('Could not save: this browser blocks local storage.'); return false; } }\n  function mapsRender() {\n    const pre = $('.m-pre'); pre.innerHTML = '';\n    PREMADE.forEach(m => {\n      const el = document.createElement('div'); el.className = 'm-item';\n      el.innerHTML = '<div class=\"m-t\"><b>' + esc(m.name) + '<\/b><span>' + mapInfo(m.data) + '<\/span><\/div>';\n      const b = document.createElement('button'); b.className = 'accent'; b.textContent = 'Load'; b.onclick = () => mapLoad(m.data, m.name);\n      el.appendChild(b); pre.appendChild(el);\n    });\n    const box = $('.m-list'); box.innerHTML = '';\n    const a = savedGet();\n    if (!a.length) box.innerHTML = '<span class=\"note\" style=\"margin:0\">Nothing saved yet.<\/span>';\n    a.forEach((e, i) => {\n      let o = null; try { o = JSON.parse(e.d); } catch (er) {}\n      const el = document.createElement('div'); el.className = 'm-item';\n      el.innerHTML = '<div class=\"m-t\"><b>' + esc(e.n) + '<\/b><span>' + (o ? mapInfo(o) : 'unreadable') + (e.t ? ' \u00b7 ' + new Date(e.t).toLocaleDateString() : '') + '<\/span><\/div>';\n      const l = document.createElement('button'); l.textContent = 'Load'; l.disabled = !o; l.onclick = () => mapLoad(o, e.n);\n      const d = document.createElement('button'); d.textContent = '\u2715'; d.className = 'danger'; d.title = 'Delete';\n      d.onclick = () => { if (confirm('Delete the saved field \"' + e.n + '\"?')) { const x = savedGet(); x.splice(i, 1); savedPut(x); mapsRender(); } };\n      el.appendChild(l); el.appendChild(d); box.appendChild(el);\n    });\n  }\n  $('.m-save').onclick = () => {\n    const name = ($('.m-name').value || '').replace(\/[<>&\"]\/g, '').trim().slice(0, 40) || 'My field', a = savedGet(), i = a.findIndex(e => e.n.toLowerCase() === name.toLowerCase());\n    if (i >= 0 && !confirm('A saved field named \"' + name + '\" already exists. Replace it?')) return;\n    if (i < 0 && a.length >= 30) { setHud('You can save up to 30 fields. Delete one first.'); return; }\n    const e = { n: name, d: serialize(), t: Date.now() };\n    if (i >= 0) a[i] = e; else a.unshift(e);\n    if (savedPut(a)) { $('.m-name').value = ''; mapsRender(); setHud('\ud83d\udcbe Saved <b>' + esc(name) + '<\/b>.'); setTimeout(hint, 2500); }\n  };\n  $('.m-dl').onclick = () => {\n    const name = (($('.m-name').value || '').replace(\/[^\\w\\- ]+\/g, '').trim() || 'paintball-field').slice(0, 40);\n    try {\n      const url = URL.createObjectURL(new Blob([serialize()], { type: 'application\/json' })), a = document.createElement('a');\n      a.href = url; a.download = name + '.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500);\n    } catch (e) { setHud('Download is not possible here. Use \"Copy field (JSON)\" in Build instead.'); }\n  };\n\n  \/* ---------- 3D print export (STL) ---------- *\/\n  \/\/ The field is scaled so its length becomes the chosen miniature length. STL units are millimetres, z is up, the red start zone is at the top (y high) like the top view.\n  const stlLen = () => cl(+$('.st-len').value || 100, 10, 2000);\n  const stlK = () => stlLen() * 10 \/ L; \/\/ mm per metre of the real field\n  function stlInfo() {\n    const k = stlK(), tall = bunkers.reduce((m, d) => Math.max(m, d.base.y * d.s.y), 0), pane = .04 * k;\n    $('.st-info').innerHTML = 'Scale <b>1 : ' + (1000 \/ k).toFixed(1) + '<\/b> \u00b7 miniature field <b>' + (W * k \/ 10).toFixed(1) + ' \u00d7 ' + (L * k \/ 10).toFixed(1) + ' cm<\/b> \u00b7 ' + bunkers.length + ' bunker' + (bunkers.length === 1 ? '' : 's') +\n      (tall ? ' \u00b7 tallest <b>' + (tall * k).toFixed(0) + ' mm<\/b>' : '') + (bunkers.some(d => d.type === 'plexi') && $('.st-glass').checked && pane < 1 ? '<br>\u26a0 The Plexi pane would be only ' + pane.toFixed(2) + ' mm thick: too thin to print. Untick the pane, or use a bigger miniature.' : '');\n  }\n  function stlTris(d, world, glass) { \/\/ triangles of one bunker in three.js coordinates (metres): [ax,ay,az,bx,by,bz,cx,cy,cz,...]\n    d.group.updateMatrixWorld(true);\n    const inv = new THREE.Matrix4().copy(d.group.matrixWorld).invert(), out = [], v = new THREE.Vector3(), M = new THREE.Matrix4();\n    const add = (geo, mat) => {\n      const p = geo.attributes.position, ix = geo.index, n = ix ? ix.count : p.count, w = [];\n      for (let i = 0; i < n; i++) { v.fromBufferAttribute(p, ix ? ix.getX(i) : i).applyMatrix4(mat); w.push(v.x, v.y, v.z); }\n      for (let i = 0; i + 8 < w.length; i += 9) out.push(w[i], w[i + 1], w[i + 2], w[i + 3], w[i + 4], w[i + 5], w[i + 6], w[i + 7], w[i + 8]);\n    };\n    const rel = o => world ? o.matrixWorld : M.multiplyMatrices(inv, o.matrixWorld);\n    d.body.updateMatrixWorld(true);\n    d.body.traverse(o => {\n      if (!o.isMesh || !o.visible || o.userData.noEdge || (o.userData.glass && !glass)) return;\n      add(o.geometry, rel(o).clone());\n    });\n    if (d.type === 'dome') { const cap = new THREE.CircleGeometry(.95, 22); cap.rotateX(Math.PI \/ 2); add(cap, rel(d.body).clone()); cap.dispose(); } \/\/ the dome is an open half sphere: close the bottom so it is watertight\n    return out;\n  }\n  function stlBytes(tris, tf) { \/\/ binary STL; tf maps a three.js point (metres) to printer millimetres\n    const n = tris.length \/ 9, buf = new ArrayBuffer(84 + n * 50), dv = new DataView(buf), q = [0, 0, 0];\n    const hd = 'Paintball Callout Trainer'; for (let i = 0; i < hd.length; i++) dv.setUint8(i, hd.charCodeAt(i));\n    dv.setUint32(80, n, true);\n    for (let t = 0, o = 84; t < n; t++, o += 50) {\n      const P = [];\n      for (let i = 0; i < 3; i++) P.push(tf(tris[t * 9 + i * 3], tris[t * 9 + i * 3 + 1], tris[t * 9 + i * 3 + 2]));\n      const ux = P[1][0] - P[0][0], uy = P[1][1] - P[0][1], uz = P[1][2] - P[0][2], vx = P[2][0] - P[0][0], vy = P[2][1] - P[0][1], vz = P[2][2] - P[0][2];\n      let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; const l = Math.hypot(nx, ny, nz) || 1; q[0] = nx \/ l; q[1] = ny \/ l; q[2] = nz \/ l;\n      for (let i = 0; i < 3; i++) dv.setFloat32(o + i * 4, q[i], true);\n      for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) dv.setFloat32(o + 12 + i * 12 + j * 4, P[i][j], true);\n    }\n    return new Uint8Array(buf);\n  }\n  const crcT = (() => { const t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();\n  const crc32 = u => { let c = 0xFFFFFFFF; for (let i = 0; i < u.length; i++) c = crcT[(c ^ u[i]) & 255] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; };\n  function zipFiles(files) { \/\/ files: [{name, data: Uint8Array}] -> stored (uncompressed) .zip\n    const enc = new TextEncoder(), parts = [], cd = []; let off = 0;\n    files.forEach(f => {\n      const nm = enc.encode(f.name), crc = crc32(f.data), h = new DataView(new ArrayBuffer(30));\n      h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x800, true); h.setUint32(14, crc, true); h.setUint32(18, f.data.length, true); h.setUint32(22, f.data.length, true); h.setUint16(26, nm.length, true);\n      parts.push(new Uint8Array(h.buffer), nm, f.data);\n      const c = new DataView(new ArrayBuffer(46));\n      c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x800, true); c.setUint32(16, crc, true); c.setUint32(20, f.data.length, true); c.setUint32(24, f.data.length, true); c.setUint16(28, nm.length, true); c.setUint32(42, off, true);\n      cd.push(new Uint8Array(c.buffer), nm); off += 30 + nm.length + f.data.length;\n    });\n    let cdLen = 0; cd.forEach(x => { cdLen += x.length; });\n    const e = new DataView(new ArrayBuffer(22)); e.setUint32(0, 0x06054b50, true); e.setUint16(8, files.length, true); e.setUint16(10, files.length, true); e.setUint32(12, cdLen, true); e.setUint32(16, off, true);\n    return new Blob(parts.concat(cd, [new Uint8Array(e.buffer)]), { type: 'application\/zip' });\n  }\n  function stlSave(blob, name) {\n    try { const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); return true; }\n    catch (e) { setHud('Download is not possible here.'); return false; }\n  }\n  const stlBase = () => (($('.m-name').value || '').replace(\/[^\\w\\- ]+\/g, '').trim() || 'paintball-field').slice(0, 40).replace(\/ +\/g, '-');\n  $('.st-len').oninput = $('.st-glass').onchange = stlInfo;\n  $('.st-len').onchange = () => { try { localStorage.setItem('pbf_stl_len', String(stlLen())); } catch (e) {} stlInfo(); };\n  try { const sv = +localStorage.getItem('pbf_stl_len'); if (sv >= 10) $('.st-len').value = sv; } catch (e) {}\n  $('.st-one').onclick = () => {\n    if (!bunkers.length) { setHud('There are no bunkers on the field to export.'); return; }\n    const k = stlK(), gl = $('.st-glass').checked, all = [];\n    bunkers.forEach(d => { const t = stlTris(d, true, gl); for (let i = 0; i < t.length; i++) all.push(t[i]); });\n    const data = stlBytes(all, (x, y, z) => [(x + W \/ 2) * k, (L \/ 2 - z) * k, y * k]);\n    if (stlSave(new Blob([data], { type: 'model\/stl' }), stlBase() + '-field-1to' + (1000 \/ k).toFixed(0) + '.stl')) setHud('\u2b07 Exported the whole field as one STL (' + (W * k \/ 10).toFixed(1) + ' \u00d7 ' + (L * k \/ 10).toFixed(1) + ' cm).');\n  };\n  $('.st-zip').onclick = () => {\n    if (!bunkers.length) { setHud('There are no bunkers on the field to export.'); return; }\n    const k = stlK(), gl = $('.st-glass').checked, groups = [], files = [], enc = new TextEncoder();\n    bunkers.forEach(d => { \/\/ identical bunkers share one file\n      const key = [d.type, d.s.x.toFixed(3), d.s.y.toFixed(3), d.s.z.toFixed(3), d.th, d.wh, d.wy, d.ww].join('|');\n      let g = groups.find(q => q.key === key); if (!g) groups.push(g = { key, d, list: [] }); g.list.push(d);\n    });\n    const lines = ['Paintball Callout Trainer - 3D print export', '', 'Field: ' + W + ' x ' + L + ' m   Miniature: ' + (W * k \/ 10).toFixed(1) + ' x ' + (L * k \/ 10).toFixed(1) + ' cm   Scale 1 : ' + (1000 \/ k).toFixed(2), 'STL units are millimetres. Every file lies flat on its base, in the orientation of rotation 0.', '',\n      'Placement on the miniature field: top-down view with the RED start zone at the top and BLUE at the bottom. x runs to the right, z runs downwards, both in cm from the centre of the field.', 'Rotation is in degrees, counter-clockwise seen from above, starting from the orientation of the printed file.', ''];\n    groups.forEach((g, i) => {\n      const t = stlTris(g.d, false, gl); let mx = 1e9, Mx = -1e9, my = 1e9, My = -1e9, mz = 1e9, Mz = -1e9;\n      for (let j = 0; j < t.length; j += 3) { mx = Math.min(mx, t[j]); Mx = Math.max(Mx, t[j]); my = Math.min(my, t[j + 1]); My = Math.max(My, t[j + 1]); mz = Math.min(mz, t[j + 2]); Mz = Math.max(Mz, t[j + 2]); }\n      const cx = (mx + Mx) \/ 2, cz = (mz + Mz) \/ 2, nm = String(i + 1).padStart(2, '0') + '_' + TYPES[g.d.type].label + '_x' + g.list.length + '.stl';\n      files.push({ name: nm, data: stlBytes(t, (x, y, z) => [(x - cx) * k, -(z - cz) * k, (y - my) * k]) });\n      lines.push(nm + '   size ' + ((Mx - mx) * k).toFixed(1) + ' x ' + ((Mz - mz) * k).toFixed(1) + ' x ' + ((My - my) * k).toFixed(1) + ' mm (w x d x h)   print ' + g.list.length + 'x');\n      g.list.forEach(d => lines.push('    ' + d.name + ': x ' + (d.x * k \/ 10).toFixed(1) + ' cm, z ' + (d.z * k \/ 10).toFixed(1) + ' cm, rotation ' + (Math.round(((d.rot * 180 \/ Math.PI) % 360 + 360) % 360) % 360) + ' deg'));\n    });\n    lines.push('', 'Note: some bunkers are made of several overlapping blocks. Slicers merge these automatically.');\n    files.push({ name: 'placement.txt', data: enc.encode(lines.join('\\r\\n')) });\n    if (stlSave(zipFiles(files), stlBase() + '-bunkers-1to' + (1000 \/ k).toFixed(0) + '.zip')) setHud('\u2b07 Exported ' + bunkers.length + ' bunkers as ' + groups.length + ' STL file' + (groups.length === 1 ? '' : 's') + ' plus a placement list.');\n  };\n  stlInfo(); $('.p-stl').addEventListener('pointerenter', stlInfo);\n  $('.m-open').onclick = () => { if (mapsOk()) $('.m-file').click(); };\n  $('.m-file').onchange = e => {\n    const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return;\n    if (f.size > 2e6) { setHud('That file is too large.'); return; }\n    const r = new FileReader();\n    r.onload = () => { let o = null; try { o = JSON.parse(String(r.result)); } catch (er) {} if (o && Array.isArray(o.b)) mapLoad(o, f.name.replace(\/\\.json$\/i, '')); else setHud('That file is not a field.'); };\n    r.readAsText(f);\n  };\n  mapsRender();\n\n  \/* ---------- side panel sections: the tabs decide what is visible, so every panel stays open ---------- *\/\n  $$('details.p-sec, details.p-net').forEach(d => { d.open = true; });\n\n  \/* ---------- weapon choice overlay: shown between rounds, while weapon switching is open ---------- *\/\n  const ws = { on: false, sig: '' }, wsEl = $('.pbf-ws');\n  const slotLeft = () => Math.max(0, Math.ceil(match.phase === 'count' ? -match.t : (isFlag() ? (fl.done ? 6 : FLAG_PAUSE) : 3) - match.t + pickT()));\n  function wsRender() {\n    const l = WLIST, mine = mySlot();\n    const slots = [1, 2, 3, 4, 5].map(k => {\n      const o = slotTaker(net.team, k, net.pid), me1 = mine === k;\n      return '<button data-slot=\"' + k + '\" class=\"' + (me1 ? 'on ' : '') + (o ? 'taken' : '') + '\">' + k + '<small>' + (me1 ? 'you' : o ? esc(o.name).slice(0, 8) : k === 1 ? 'left' : k === 5 ? 'right' : 'free') + '<\/small><\/button>';\n    }).join('');\n    wsEl.innerHTML = '<div class=\"ws-t\">Choose your start spot <span style=\"font-weight:600;opacity:.7\">\u00b7 auto in ' + slotLeft() + 's<\/span><\/div><div class=\"ws-b ws-s\">' + slots + '<\/div>' +\n      '<div class=\"ws-t\">Bullet color<\/div><div class=\"ws-b ws-c\">' + ballSwAll() + '<\/div>' +\n      '<div class=\"ws-t\">Choose your weapon<\/div><div class=\"ws-b\">' +\n      l.map(w => '<button data-w=\"' + w + '\" class=\"' + (w === weapon ? 'on' : '') + '\">' + WNAME[w] + '<small>' + (touchMode ? '&nbsp;' : 'Shift+' + (WLIST.indexOf(w) + 1)) + '<\/small><\/button>').join('') + '<\/div>' +\n      '<div class=\"ws-b\">' + (isFlag() ? '' : '<button data-team=\"1\" class=\"ws-sp\">\u21c4 Switch to ' + (net.team ? 'Red' : 'Blue') + ' team<small>' + (touchMode ? '&nbsp;' : 'key ' + kname(binds.team) + ' \u00b7 controller X') + '<\/small><\/button>') +\n      '<button data-spec=\"1\" class=\"ws-sp\">\ud83d\udc41 Spectate the next match<small>' + (touchMode ? '&nbsp;' : 'key ' + kname(binds.spec) + ' \u00b7 controller R3') + '<\/small><\/button><\/div>' +\n      '<div class=\"ws-h\">' + (touchMode ? 'Tap a spot and a weapon. ' : 'Press 1-5 for a spot (taken spots are locked), click or Shift+1-' + l.length + ' \/ mouse wheel for a weapon. Controller: D-pad \u25b2 \u25bc spot, \u25c0 \u25b6 \/ Y weapon. Tab frees the mouse. ') + 'No choice = a spot is given to you. Locks when the countdown starts.<\/div>';\n  }\n  const slotGrp = new THREE.Group(), slotMk = [];\n  slotGrp.visible = false; scene.add(slotGrp);\n  for (let k = 1; k <= SLOTS; k++) {\n    const cv = document.createElement('canvas'); cv.width = cv.height = 64; const g = cv.getContext('2d');\n    g.strokeStyle = '#fff'; g.fillStyle = '#fff'; g.lineWidth = 5; g.beginPath(); g.arc(32, 32, 27, 0, Math.PI * 2); g.stroke();\n    g.font = 'bold 36px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(String(k), 32, 35);\n    const mk = new THREE.Mesh(new THREE.PlaneGeometry(.7, .7), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(cv), transparent: true, depthWrite: false }));\n    mk.rotation.x = -Math.PI \/ 2; mk.raycast = () => {}; slotGrp.add(mk); slotMk.push(mk);\n  }\n  function wsMarkers(show) { \/\/ numbered rings on the ground at the 5 spots of your team\n    slotGrp.visible = show; if (!show) return;\n    const mine = mySlot();\n    slotMk.forEach((mk, i) => { const k = i + 1, sp = slotPos(net.team, k); mk.position.set(sp.x, .05, sp.z); mk.material.color.setHex(mine === k ? 0xffd84a : slotTaker(net.team, k, net.pid) ? 0xff5a5a : 0x2ecc71); });\n  }\n  function wsSlot(k) { \/\/ pick start spot k (1-5)\n    if (!ws.on || net.coach) return;\n    if (k === mySlot()) return;\n    const o = slotTaker(net.team, k, net.pid);\n    if (o) { setHud('\ud83d\udd12 Spot ' + k + ' is taken by ' + esc(o.name) + '.'); setTimeout(hint, 1500); return; }\n    if (net.host) { if (hostSlot(net.pid, k)) { sendRoster(); renderRoster(); placeAtStart(); } }\n    else net.send({ t: 'slot', v: k });\n    ws.sig = ''; wsUpdate();\n  }\n  function wsSlotStep(d) { \/\/ controller: move to the next \/ previous free spot\n    const m0 = mySlot() || (d > 0 ? 0 : SLOTS + 1);\n    for (let i = 1; i <= SLOTS; i++) { const k = ((m0 - 1 + d * i) % SLOTS + SLOTS) % SLOTS + 1; if (!slotTaker(net.team, k, net.pid)) { wsSlot(k); return; } }\n  }\n  function wsUpdate() {\n    const show = fp.on && !net.coach && match.on && matchOpen();\n    if (show !== ws.on) { ws.on = show; wsEl.hidden = !show; }\n    const sig = show ? weapon + '|' + ballCi + '|' + touchMode + '|' + net.team + '|' + isFlag() + '|' + slotLeft() + '|' + [1, 2, 3, 4, 5].map(k => { const o = slotTaker(net.team, k, -1); return o ? o.pid : 0; }).join(',') : '';\n    if (sig !== ws.sig) { ws.sig = sig; if (show) wsRender(); }\n    wsMarkers(show);\n  }\n  function wsTeam() { \/\/ switch to the other team (not available in the NSL Flag mode)\n    if (isFlag()) return;\n    const t = net.team ? 0 : 1; let n = 0; net.players.forEach(p => { if (!p.coach && p.team === t) n++; });\n    if (n >= TEAMMAX) { setHud('\ud83d\udd12 The ' + TN[t] + ' team is full.'); setTimeout(hint, 2000); return; }\n    const sel = $('.n-team'); sel.value = t; sel.dispatchEvent(new Event('change')); \/\/ same path as the Team box: the host decides\n  }\n  function wsSpectate() { \/\/ become a spectator (does not take part in the next round); goes through the normal Spectator mode switch\n    const c = $('.n-coach'); c.checked = true; c.dispatchEvent(new Event('change'));\n  }\n  wsEl.addEventListener('pointerdown', e => {\n    const b = e.target.closest ? e.target.closest('[data-w],[data-spec],[data-team],[data-slot],[data-ball]') : null; if (!b) return;\n    e.preventDefault(); e.stopPropagation();\n    if (b.dataset.ball) { setBall(+b.dataset.ball); return; }\n    if (b.dataset.slot) { wsSlot(+b.dataset.slot); return; }\n    if (b.dataset.spec) { wsSpectate(); return; }\n    if (b.dataset.team) { wsTeam(); return; }\n    setWeapon(b.dataset.w);\n    if (!touchMode && !fp.noLock && canvas.requestPointerLock && document.pointerLockElement !== canvas) { try { canvas.requestPointerLock(); } catch (er) {} } \/\/ go back to mouse look\n    ws.sig = ''; wsUpdate();\n  });\n\n  \/* ---------- scoreboard (hold B, controller Back\/Select, or the \ud83d\udcca button) ---------- *\/\n  const stats = new Map(), ZERO = { k: 0, d: 0, s: 0, h: 0, n: 0 }; \/\/ pid -> hits, got hit, shots, flag hangs, snatches (counted by the host, sent to everybody)\n  const stat = pid => { let o = stats.get(pid); if (!o) { o = { k: 0, d: 0, s: 0, h: 0, n: 0 }; stats.set(pid, o); } return o; };\n  const sb = { on: false, t: 0 }, sbEl = $('.pbf-sb');\n  const MODE_NAME = { free: 'Free play', match: 'Match \u00b7 team elimination', buzzer: 'Buzzer', flag7: 'Flag \u00b7 NSL rulebook v7.0 (May 2026)' };\n  const SBCOL = ['#ff8e91', '#7fb0ff'];\n  function sbShow(v) { sb.on = !!v; sbEl.hidden = !sb.on; sb.t = 0; if (sb.on) sbRender(); }\n  function sbRender() {\n    const mode = $('.n-mode').value, fm = isFlag(), mm = mode !== 'free', live = match.on;\n    let list = Array.from(net.players.values());\n    if (!list.length) list = [{ pid: net.pid, team: net.team, name: 'You', coach: false, out: false }];\n    const pl = list.filter(p => !p.coach), sp = list.filter(p => p.coach), g = pid => stats.get(pid) || ZERO;\n    const tot = [0, 0], al = [0, 0]; pl.forEach(p => { tot[p.team] += g(p.pid).k; if (!p.out) al[p.team]++; });\n    const sc = net.score.t, val = mm ? sc : tot, vlab = fm ? 'points' : mm ? 'rounds won' : 'hits';\n    let sub = '';\n    if (fm) {\n      if (live) {\n        const car = fl.st === 0 ? net.players.get(fl.c) : null;\n        sub = 'Round ' + Math.min(fl.rn, FLAG_ROUNDS) + '\/' + FLAG_ROUNDS + ' \u00b7 ' + (fl.bonus ? '\u2b50 Bonus try (1v1)' : 'Try ' + fl.tn + '\/' + FLAG_TRIES) + ' \u00b7 <b style=\"color:' + SBCOL[fl.att] + '\">' + TN[fl.att] + '<\/b> attacks \u00b7 try time ' + mmss(fl.tt) +\n              (fl.bz >= 0 ? ' \u00b7 \ud83d\udd14 buzzer ' + Math.ceil(fl.bz) + 's' : '') + '<br>\ud83d\udea9 Flag: ' + (fl.st === 0 ? (car ? 'carried by <b>' + esc(car.name) + '<\/b>' : 'carried') : 'on the floor') + ' \u00b7 alive ' + al[0] + ' v ' + al[1];\n      } else sub = 'No match running. The host starts it from the online panel.';\n    } else if (mm) sub = live ? 'Round in progress \u00b7 alive ' + al[0] + ' v ' + al[1] + (mode === 'buzzer' ? ' \u00b7 touch the enemy start zone to win the round' : '') : 'No match running. The host starts it from the online panel.';\n    else sub = 'Free play: hits count as long as you stay in the game.';\n    const head = ['<th>Player<\/th>', '<th>Hits<\/th>', '<th>Got hit<\/th>', '<th>Shots<\/th>', '<th>Accuracy<\/th>'];\n    if (mm && live) head.push('<th>Status<\/th>');\n    if (fm) head.push('<th>Flag hangs<\/th>', '<th>Snatches<\/th>');\n    const row = p => {\n      const s = g(p.pid), acc = s.s ? Math.round(s.k \/ s.s * 100) + '%' : '\u2013';\n      return '<tr class=\"' + (p.pid === net.pid ? 'me ' : '') + (live && p.out ? 'out' : '') + '\"><td>' + esc(p.name) + (p.pid === net.pid ? ' (you)' : '') + (fm && live && fl.st === 0 && fl.c === p.pid ? ' \ud83d\udea9' : '') + '<\/td><td>' + s.k + '<\/td><td>' + s.d + '<\/td><td>' + s.s + '<\/td><td>' + acc + '<\/td>' +\n        (mm && live ? '<td>' + (p.out ? '\u2716 out' : '\u2714 alive') + '<\/td>' : '') + (fm ? '<td>' + s.h + '<\/td><td>' + s.n + '<\/td>' : '') + '<\/tr>';\n    };\n    const tbl = t => {\n      const rows = pl.filter(p => p.team === t).sort((a, b) => g(b.pid).k - g(a.pid).k || a.pid - b.pid).map(row).join('');\n      return '<div class=\"sb-t\"><div class=\"sb-th\" style=\"color:' + SBCOL[t] + '\">' + TN[t] + ' \u00b7 ' + val[t] + ' ' + vlab + '<\/div><table><tr>' + head.join('') + '<\/tr>' + (rows || '<tr><td colspan=\"' + head.length + '\" style=\"text-align:left;color:#8fa0b3\">No players<\/td><\/tr>') + '<\/table><\/div>';\n    };\n    sbEl.innerHTML = '<div class=\"sb-h\"><b>' + MODE_NAME[mode] + '<\/b><\/div><div class=\"sb-s\">' + sub + '<\/div>' + tbl(0) + tbl(1) +\n      (sp.length ? '<div class=\"sb-sp\">\ud83d\udc41 Spectators: ' + sp.map(p => esc(p.name)).join(', ') + '<\/div>' : '') +\n      '<div class=\"sb-f\">' + (ncon() ? '' : 'Join an online game to see every player here. ') + 'Release the button to close.<\/div>';\n  }\n  document.addEventListener('keydown', e => {\n    if (canon(e.code) !== 'KeyB' || e.repeat || e.ctrlKey || e.metaKey || e.altKey || \/INPUT|SELECT|TEXTAREA\/.test((e.target.tagName || ''))) return;\n    if (fp.on || net.coach || mode === 'play') { sbShow(true); e.preventDefault(); }\n  });\n  document.addEventListener('keyup', e => { if (canon(e.code) === 'KeyB') sbShow(false); });\n  window.addEventListener('blur', () => sbShow(false));\n  hold($('.tb-b'), () => sbShow(true), () => sbShow(false));\n  $('.c-play').onclick = () => { const c = $('.n-coach'); c.checked = false; c.dispatchEvent(new Event('change')); };\n  hold($('.c-sb'), () => sbShow(true), () => sbShow(false));\n\n  \/* ---------- visitor counter (peer-to-peer, no server of our own) ---------- *\/\n  \/\/ The first visitor claims a fixed PeerJS id and acts as the \"hub\"; everybody else connects to it and sends a heartbeat.\n  \/\/ The hub counts the visitors whose tab is visible and tells everyone. If the hub leaves, somebody else takes over.\n  const PRES_ID = 'pbf-presence-hub', visEl = $('.v-n');\n  const pres = { peer: null, conn: null, hub: false, conns: new Map(), n: 0, sent: -1, hb: 0, sweep: 0, timer: 0, tries: 0 };\n  const presVisible = () => document.hidden ? 0 : 1;\n  function presShow(n) { pres.n = n; visEl.textContent = n > 0 ? n : '\u2013'; }\n  function presCount() { let n = presVisible(); pres.conns.forEach(c => { if (c.vis) n++; }); return n; }\n  function presBroadcast(force) {\n    const n = presCount(); presShow(n);\n    if (!force && n === pres.sent) return;\n    pres.sent = n; pres.conns.forEach(c => { if (c.open) { try { c.send({ t: 'n', n }); } catch (e) {} } });\n  }\n  function presStop() {\n    clearInterval(pres.hb); clearInterval(pres.sweep); clearTimeout(pres.timer);\n    const p = pres.peer; pres.peer = null; pres.conn = null; pres.hub = false; pres.conns = new Map(); pres.sent = -1;\n    try { if (p) p.destroy(); } catch (e) {}\n  }\n  function presStart(ms) { clearTimeout(pres.timer); pres.timer = setTimeout(presHub, ms); }\n  function presRetry() { presStop(); pres.tries++; presStart(1000 + Math.random() * 3000 * Math.min(pres.tries, 5)); }\n  function presSweep() { \/\/ the hub forgets visitors that stopped sending heartbeats\n    const now = Date.now(); let ch = false;\n    pres.conns.forEach((c, k) => { if (now - c.last > 90000) { try { c.close(); } catch (e) {} pres.conns.delete(k); ch = true; } });\n    if (ch) presBroadcast();\n  }\n  function presHub() { \/\/ try to become the hub; if the id is taken, join the existing hub as a client\n    if (!window.Peer) return;\n    const p = new Peer(PRES_ID); pres.peer = p;\n    p.on('open', () => { pres.hub = true; pres.tries = 0; pres.conns = new Map(); presBroadcast(true); pres.sweep = setInterval(presSweep, 15000); });\n    p.on('connection', c => {\n      if (pres.conns.size >= 300) { c.on('open', () => { try { c.close(); } catch (e) {} }); return; }\n      c.vis = 1; c.last = Date.now(); pres.conns.set(c.peer, c);\n      c.on('open', () => presBroadcast(true));\n      c.on('data', m => { if (m && m.t === 'h') { c.last = Date.now(); const v = m.v ? 1 : 0; if (v !== c.vis) { c.vis = v; presBroadcast(); } } });\n      const drop = () => { if (pres.conns.get(c.peer) === c) { pres.conns.delete(c.peer); presBroadcast(); } };\n      c.on('close', drop); c.on('error', drop);\n    });\n    p.on('disconnected', () => { if (pres.peer === p) { try { p.reconnect(); } catch (e) {} } });\n    p.on('error', err => {\n      if (pres.peer !== p) return;\n      if (err.type === 'unavailable-id') { try { p.destroy(); } catch (e) {} pres.peer = null; presClient(); } else presRetry();\n    });\n  }\n  function presClient() {\n    const q = new Peer(); pres.peer = q;\n    q.on('open', () => {\n      if (pres.peer !== q) return;\n      const c = q.connect(PRES_ID, { reliable: true }); pres.conn = c;\n      c.on('open', () => {\n        pres.tries = 0;\n        try { c.send({ t: 'h', v: presVisible() }); } catch (e) {}\n        pres.hb = setInterval(() => { try { c.send({ t: 'h', v: presVisible() }); } catch (e) {} }, 15000);\n      });\n      c.on('data', m => { if (m && m.t === 'n') presShow(m.n | 0); });\n      c.on('close', () => { if (pres.conn === c) presRetry(); });\n      c.on('error', () => {});\n    });\n    q.on('error', () => { if (pres.peer === q) presRetry(); });\n  }\n  document.addEventListener('visibilitychange', () => {\n    if (pres.hub) presBroadcast(); else if (pres.conn && pres.conn.open) { try { pres.conn.send({ t: 'h', v: presVisible() }); } catch (e) {} }\n  });\n  window.addEventListener('beforeunload', presStop);\n  ensurePeer(ok => { if (ok) presStart(1500 + Math.random() * 1500); });\n\n  \/* ---------- strategy board: plan players, paths, shot lines and notes in steps (for coaches) ---------- *\/\n  const STC = ['#e5483f', '#2f7de1', '#f2b632', '#3fb669', '#b07cf0', '#ff8a3d', '#ffffff', '#26c6da'], TEAMC = ['#e5483f', '#2f7de1'];\n  const strat = { steps: [{ nm: 'Step 1', it: [] }], cur: 0, tool: 'sel', sel: null, show: [true, true], names: true, dash: false, lc: '#f2b632' };\n  let stId = 1;\n  const stG = new THREE.Group(); stG.visible = false; scene.add(stG);\n  const stCur = () => strat.steps[strat.cur];\n  const stItem = id => stCur().it.find(o => o.id === id);\n  const stSelItem = () => strat.sel ? stItem(strat.sel.id) || null : null;\n  const stCx = x => cl(+x || 0, -W \/ 2 + .3, W \/ 2 - .3), stCz = z => cl(+z || 0, -L \/ 2 + .3, L \/ 2 - .3);\n  const stEmpty = () => strat.steps.length === 1 && !strat.steps[0].it.length && strat.steps[0].nm === 'Step 1';\n  function stSer() { \/\/ compact copy for saving with the field (undefined when nothing is planned)\n    if (stEmpty()) return undefined;\n    const r = v => +(+v).toFixed(2);\n    return strat.steps.map(s => ({ n: s.nm, i: s.it.map(o => o.k === 'p' ? { k: 'p', tm: o.tm, n: o.n, nm: o.nm, x: r(o.x), z: r(o.z), c: o.c, p: o.p.map(q => [r(q[0]), r(q[1]), q[2] ? 1 : 0]) }\n      : o.k === 'l' ? { k: 'l', a: o.a.map(r), b: o.b.map(r), c: o.c, t: o.t, d: o.d ? 1 : 0 } : { k: 't', x: r(o.x), z: r(o.z), c: o.c, t: o.t }) }));\n  }\n  function stClean(o) { \/\/ one saved item -> checked item (or null)\n    if (!o || typeof o !== 'object') return null;\n    const num = v => isFinite(+v) ? +v : 0, col = (c, d) => okHex(c) ? c.toLowerCase() : d, txt = (t, n) => String(t == null ? '' : t).replace(\/[<>]\/g, '').slice(0, n);\n    if (o.k === 'p') return { id: stId++, k: 'p', tm: o.tm ? 1 : 0, n: cl(Math.round(num(o.n)) || 1, 1, 10), nm: txt(o.nm, 14), x: stCx(o.x), z: stCz(o.z), c: col(o.c, TEAMC[o.tm ? 1 : 0]), p: (Array.isArray(o.p) ? o.p : []).slice(0, 40).map(q => [stCx(q && q[0]), stCz(q && q[1]), q && q[2] ? 1 : 0]) };\n    if (o.k === 'l' && Array.isArray(o.a) && Array.isArray(o.b)) return { id: stId++, k: 'l', a: [stCx(o.a[0]), stCz(o.a[1])], b: [stCx(o.b[0]), stCz(o.b[1])], c: col(o.c, '#f2b632'), t: txt(o.t, 30), d: o.d ? 1 : 0 };\n    if (o.k === 't') return { id: stId++, k: 't', x: stCx(o.x), z: stCz(o.z), c: col(o.c, '#ffffff'), t: txt(o.t, 60) };\n    return null;\n  }\n  function stLoad(a) {\n    stId = 1; strat.sel = null;\n    const steps = Array.isArray(a) ? a.slice(0, 30).map(s => ({ nm: String(s && s.n || 'Step').replace(\/[<>]\/g, '').slice(0, 40), it: (Array.isArray(s && s.i) ? s.i : []).slice(0, 150).map(stClean).filter(Boolean) })) : [];\n    strat.steps = steps.length ? steps : [{ nm: 'Step 1', it: [] }]; strat.cur = 0;\n    stRender(); stUI();\n  }\n  const stSave = () => persist();\n  \/\/ textures (number tokens and text labels), cached by what they show\n  const stTex = new Map();\n  function stT(key, w, h, draw) {\n    let t = stTex.get(key); if (t) return t;\n    if (stTex.size > 400) { stTex.forEach(v => v.dispose()); stTex.clear(); }\n    const c = document.createElement('canvas'); c.width = w; c.height = h; draw(c.getContext('2d'), w, h);\n    t = new THREE.CanvasTexture(c); t.minFilter = THREE.LinearFilter; stTex.set(key, t); return t;\n  }\n  function stSprite(tex, x, z, sx, sy, order) {\n    const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, depthWrite: false, transparent: true }));\n    s.position.set(x, .3, z); s.scale.set(sx, sy, 1); s.renderOrder = order; stG.add(s); return s;\n  }\n  function stLabel(text, col, x, z, k, dark) { \/\/ text in a rounded box\n    const tex = stT('L|' + text + '|' + col + '|' + (dark ? 1 : 0), 512, 96, (g, w, h) => {\n      g.font = 'bold 46px system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';\n      const bw = Math.min(500, g.measureText(text).width + 44);\n      g.fillStyle = dark ? 'rgba(12,16,22,.9)' : 'rgba(12,16,22,.78)'; g.strokeStyle = col; g.lineWidth = 6;\n      g.beginPath(); g.roundRect ? g.roundRect((w - bw) \/ 2, 8, bw, h - 16, 24) : g.rect((w - bw) \/ 2, 8, bw, h - 16); g.fill(); g.stroke();\n      g.fillStyle = '#fff'; g.fillText(text, w \/ 2, h \/ 2 + 2, 460);\n    });\n    return stSprite(tex, x, z, 3.2 * k, .6 * k, 30);\n  }\n  function stRender() {\n    while (stG.children.length) { const o = stG.children[0]; stG.remove(o); if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }\n    const S = stCur(), sl = strat.sel, Y = .06, TAU = Math.PI * 2;\n    const mesh = (P, col, order, op) => {\n      if (!P.length) return;\n      const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));\n      const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: op == null ? .95 : op, depthTest: false, depthWrite: false, side: THREE.DoubleSide })); m.renderOrder = order; stG.add(m);\n    };\n    const quad = (P, ax, az, bx, bz, w) => { const dx = bx - ax, dz = bz - az, l = Math.hypot(dx, dz) || 1, nx = -dz \/ l * w \/ 2, nz = dx \/ l * w \/ 2; P.push(ax + nx, Y, az + nz, ax - nx, Y, az - nz, bx + nx, Y, bz + nz, bx + nx, Y, bz + nz, ax - nx, Y, az - nz, bx - nx, Y, bz - nz); };\n    const disc = (P, x, z, r) => { for (let i = 0; i < 16; i++) { const a = i \/ 16 * TAU, b = (i + 1) \/ 16 * TAU; P.push(x, Y, z, x + Math.cos(a) * r, Y, z + Math.sin(a) * r, x + Math.cos(b) * r, Y, z + Math.sin(b) * r); } };\n    const head = (P, ax, az, bx, bz, w) => { const dx = bx - ax, dz = bz - az, l = Math.hypot(dx, dz) || 1, ux = dx \/ l, uz = dz \/ l, hl = w * 3.4, hw = w * 2.3, cx = bx - ux * hl, cz = bz - uz * hl; P.push(bx + ux * w * .6, Y, bz + uz * w * .6, cx - uz * hw, Y, cz + ux * hw, cx + uz * hw, Y, cz - ux * hw); };\n    const stroke = (P, ax, az, bx, bz, w, dashed) => {\n      const dx = bx - ax, dz = bz - az, l = Math.hypot(dx, dz); if (l < 1e-3) return;\n      if (!dashed) { quad(P, ax, az, bx, bz, w); return; }\n      const on = .5 + w, off = .38; for (let t = 0; t < l; t += on + off) { const e = Math.min(l, t + on); quad(P, ax + dx * t \/ l, az + dz * t \/ l, ax + dx * e \/ l, az + dz * e \/ l, w); }\n    };\n    S.it.forEach(o => {\n      const isSel = sl && sl.id === o.id;\n      if (o.k === 'p') {\n        if (!strat.show[o.tm]) return;\n        if (o.p.length) {\n          const pts = [[o.x, o.z, 0]].concat(o.p);\n          [isSel ? .38 : 0, 0].forEach((extra, pass) => { \/\/ first a white outline under the selected path, then the coloured path\n            const P = [], w = .2 + extra;\n            for (let i = 1; i < pts.length; i++) { const a = pts[i - 1], b = pts[i]; stroke(P, a[0], a[1], b[0], b[1], w, b[2]); if (i < pts.length - 1) disc(P, b[0], b[1], w * .75); }\n            const n = pts.length, a = pts[n - 2], b = pts[n - 1]; head(P, a[0], a[1], b[0], b[1], w * (pass ? 1 : .9));\n            if (!pass && !isSel) return;\n            mesh(P, pass ? o.c : '#ffffff', pass ? 15 : 14, pass ? .95 : .9);\n          });\n          if (isSel) { const P = []; o.p.forEach((q, i) => { if (i < o.p.length - 1) disc(P, q[0], q[1], .34); }); mesh(P, '#ffffff', 16, 1); const Q = []; o.p.forEach((q, i) => { if (i < o.p.length - 1) disc(Q, q[0], q[1], .2); }); mesh(Q, o.c, 17, 1); }\n          if (isSel && sl.part === 'wp' && o.p[sl.i]) { const P = []; disc(P, o.p[sl.i][0], o.p[sl.i][1], .52); mesh(P, '#ffd54f', 18, 1); }\n        }\n        const rim = TEAMC[o.tm], tex = stT('T|' + o.n + '|' + o.c + '|' + rim + '|' + (isSel ? 1 : 0), 128, 128, (g) => {\n          g.beginPath(); g.arc(64, 64, 62, 0, TAU); g.fillStyle = isSel ? '#ffd54f' : '#ffffff'; g.fill();\n          g.beginPath(); g.arc(64, 64, 55, 0, TAU); g.fillStyle = rim; g.fill();\n          g.beginPath(); g.arc(64, 64, 42, 0, TAU); g.fillStyle = o.c; g.fill();\n          g.font = 'bold 58px system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = lumOf(o.c) > .6 ? '#111' : '#fff'; g.fillText(String(o.n), 64, 68);\n        });\n        stSprite(tex, o.x, o.z, 1.3, 1.3, 25);\n        if (strat.names && o.nm) stLabel(o.nm, rim, o.x, o.z + 1.1, .85, false);\n      } else if (o.k === 'l') {\n        [isSel ? .34 : 0, 0].forEach((extra, pass) => {\n          if (!pass && !isSel) return;\n          const P = [], w = .17 + extra; stroke(P, o.a[0], o.a[1], o.b[0], o.b[1], w, o.d); head(P, o.a[0], o.a[1], o.b[0], o.b[1], w * (pass ? 1 : .8));\n          mesh(P, pass ? o.c : '#ffffff', pass ? 13 : 12, pass ? .9 : .9);\n        });\n        const D = []; disc(D, o.a[0], o.a[1], .22); mesh(D, o.c, 13, 1);\n        if (isSel) { const P = []; disc(P, o.a[0], o.a[1], .4); disc(P, o.b[0], o.b[1], .4); mesh(P, '#ffd54f', 18, .95); }\n        if (o.t) stLabel(o.t, o.c, (o.a[0] + o.b[0]) \/ 2, (o.a[1] + o.b[1]) \/ 2 + .55, .8, true);\n      } else if (o.k === 't') {\n        if (isSel) { const P = []; disc(P, o.x, o.z, .3); mesh(P, '#ffd54f', 18, .95); }\n        stLabel(o.t || 'Note', o.c, o.x, o.z, 1.1, true);\n      }\n    });\n  }\n  \/* hit testing on the ground *\/\n  const stSegD = (px, pz, ax, az, bx, bz) => { const ex = bx - ax, ez = bz - az, l2 = ex * ex + ez * ez || 1e-9, t = cl(((px - ax) * ex + (pz - az) * ez) \/ l2, 0, 1); return Math.hypot(px - ax - ex * t, pz - az - ez * t); };\n  function stHit(gp) {\n    const S = stCur(), kk = touchMode ? 1.4 : 1, sel = stSelItem();\n    if (sel && sel.k === 'p' && strat.show[sel.tm]) for (let i = sel.p.length - 1; i >= 0; i--) if (Math.hypot(gp.x - sel.p[i][0], gp.z - sel.p[i][1]) < .55 * kk) return { id: sel.id, part: 'wp', i };\n    let best = null, bd = 1e9;\n    const take = (id, part, d, lim) => { if (d < lim && d < bd) { bd = d; best = { id, part }; } };\n    S.it.forEach(o => {\n      if (o.k === 'p' && strat.show[o.tm]) take(o.id, 'tok', Math.hypot(gp.x - o.x, gp.z - o.z), .75 * kk);\n      else if (o.k === 'l') { take(o.id, 'a', Math.hypot(gp.x - o.a[0], gp.z - o.a[1]), .6 * kk); take(o.id, 'b', Math.hypot(gp.x - o.b[0], gp.z - o.b[1]), .6 * kk); }\n      else if (o.k === 't') take(o.id, 'note', Math.hypot(gp.x - o.x, gp.z - o.z), 1 * kk);\n    });\n    if (best) return best;\n    S.it.forEach(o => {\n      if (o.k === 'l') take(o.id, 'mid', stSegD(gp.x, gp.z, o.a[0], o.a[1], o.b[0], o.b[1]), .4 * kk);\n      else if (o.k === 'p' && strat.show[o.tm]) { let p = [o.x, o.z]; o.p.forEach(q => { take(o.id, 'seg', stSegD(gp.x, gp.z, p[0], p[1], q[0], q[1]), .35 * kk); p = q; }); }\n    });\n    return best;\n  }\n  const stGP = () => { const g = groundPoint(); return g ? { x: stCx(g.x), z: stCz(g.z) } : null; };\n  function stSelect(h) { strat.sel = h ? { id: h.id, part: h.part, i: h.i } : null; stRender(); stUI(); }\n  function stDown(e, force) { \/\/ pointer pressed on the field in Strategy mode: returns a drag state when it grabbed something\n    if (force) return null;\n    const gp = stGP(); if (!gp) return null;\n    if (strat.tool === 'line') {\n      const o = { id: stId++, k: 'l', a: [gp.x, gp.z], b: [gp.x, gp.z], c: strat.lc, t: '', d: 1 }; stCur().it.push(o); strat.sel = { id: o.id, part: 'b' }; stRender();\n      return { mode: 'sline', o, moved: false, sx: e.clientX, sy: e.clientY };\n    }\n    if (strat.tool !== 'sel') return null;\n    const h = stHit(gp); if (!h) return null;\n    stSelect(h);\n    if (h.part === 'seg') return null;\n    const o = stItem(h.id); if (!o) return null;\n    const s = { mode: 'sdrag', h, o, moved: false, sx: e.clientX, sy: e.clientY, gx: gp.x, gz: gp.z };\n    if (h.part === 'tok' || h.part === 'note') { s.dx = o.x - gp.x; s.dz = o.z - gp.z; }\n    else if (h.part === 'wp') { s.dx = o.p[h.i][0] - gp.x; s.dz = o.p[h.i][1] - gp.z; }\n    else if (h.part === 'a') { s.dx = o.a[0] - gp.x; s.dz = o.a[1] - gp.z; }\n    else if (h.part === 'b') { s.dx = o.b[0] - gp.x; s.dz = o.b[1] - gp.z; }\n    else if (h.part === 'mid') { s.a0 = o.a.slice(); s.b0 = o.b.slice(); }\n    return s;\n  }\n  function stMove(s, gp) {\n    const o = s.o;\n    if (s.mode === 'sline') { o.b = [gp.x, gp.z]; stRender(); return; }\n    const p = s.h.part;\n    if (p === 'tok' || p === 'note') { o.x = stCx(gp.x + s.dx); o.z = stCz(gp.z + s.dz); }\n    else if (p === 'wp') { o.p[s.h.i][0] = stCx(gp.x + s.dx); o.p[s.h.i][1] = stCz(gp.z + s.dz); }\n    else if (p === 'a') o.a = [stCx(gp.x + s.dx), stCz(gp.z + s.dz)];\n    else if (p === 'b') o.b = [stCx(gp.x + s.dx), stCz(gp.z + s.dz)];\n    else if (p === 'mid') { const mx = gp.x - s.gx, mz = gp.z - s.gz; o.a = [stCx(s.a0[0] + mx), stCz(s.a0[1] + mz)]; o.b = [stCx(s.b0[0] + mx), stCz(s.b0[1] + mz)]; }\n    stRender();\n  }\n  function stEnd(s) {\n    if (s.mode === 'sline' && Math.hypot(s.o.b[0] - s.o.a[0], s.o.b[1] - s.o.a[1]) < .8) { const it = stCur().it; it.splice(it.indexOf(s.o), 1); strat.sel = null; }\n    stRender(); stUI(); stSave();\n  }\n  function stClick() { \/\/ tap without dragging\n    const gp = stGP(); if (!gp) return;\n    const t = strat.tool, S = stCur();\n    if (t === 'red' || t === 'blue') {\n      const tm = t === 'blue' ? 1 : 0, used = S.it.filter(o => o.k === 'p' && o.tm === tm).map(o => o.n); let n = 1; while (used.indexOf(n) >= 0) n++;\n      if (n > 10 || S.it.length >= 150) { setHud('That is the maximum number of players for a team.'); return; }\n      const o = { id: stId++, k: 'p', tm, n, nm: '', x: gp.x, z: gp.z, c: TEAMC[tm], p: [] }; S.it.push(o); strat.sel = { id: o.id, part: 'tok' };\n    } else if (t === 'path') {\n      const h = stHit(gp), cur = stSelItem();\n      if (h && h.part === 'tok') { strat.sel = { id: h.id, part: 'tok' }; }\n      else if (cur && cur.k === 'p') { if (cur.p.length >= 40) { setHud('A path can have 40 points at most.'); return; } cur.p.push([gp.x, gp.z, strat.dash ? 1 : 0]); strat.sel = { id: cur.id, part: 'wp', i: cur.p.length - 1 }; }\n      else { setHud('Tap a player first, then tap the field to add path points.'); return; }\n    } else if (t === 'note') {\n      if (S.it.length >= 150) return;\n      const o = { id: stId++, k: 't', x: gp.x, z: gp.z, c: '#ffffff', t: 'Note' }; S.it.push(o); strat.sel = { id: o.id, part: 'note' };\n    } else if (t === 'sel') {\n      const h = stHit(gp); strat.sel = h && h.part !== 'seg' ? { id: h.id, part: h.part, i: h.i } : (h ? { id: h.id, part: 'tok' } : null);\n    } else return;\n    stRender(); stUI(); stSave();\n    if (strat.sel && (t === 'note')) { const i = $('.st-tx'); if (i && !touchMode) { i.focus(); i.select(); } }\n  }\n  \/* panel *\/\n  function stRemove() {\n    const o = stSelItem(); if (!o) return;\n    if (strat.sel.part === 'wp' && o.k === 'p') { o.p.splice(strat.sel.i, 1); strat.sel = { id: o.id, part: 'tok' }; }\n    else { const it = stCur().it; it.splice(it.indexOf(o), 1); strat.sel = null; }\n    stRender(); stUI(); stSave();\n  }\n  function stTool(t) {\n    strat.tool = t; $$('.st-tools button').forEach(b => b.classList.toggle('on', b.dataset.t === t)); stHint();\n  }\n  function stHint() {\n    if (mode !== 'strat') return;\n    setHud(({ sel: 'Drag players, path points, lines and notes to move them. Tap one to edit it in the side panel.', red: 'Tap the field to place a <b>Red<\/b> player.', blue: 'Tap the field to place a <b>Blue<\/b> player.',\n      path: 'Tap a player, then tap the field to add <b>path points<\/b> in order. Drag a point with the Move tool to adjust it.', line: 'Drag on the field to draw a <b>shot line<\/b> (a lane, a bunker to cover...).', note: 'Tap the field to place a <b>note<\/b>, then type the text in the side panel.' })[strat.tool] || '');\n  }\n  function stGo(i) { strat.cur = cl(i, 0, strat.steps.length - 1); strat.sel = null; stRender(); stUI(); }\n  function stUI() {\n    const S = stCur(), box = $('.st-steps'); box.innerHTML = '';\n    strat.steps.forEach((s, i) => { const b = document.createElement('button'); b.textContent = i + 1; b.title = s.nm; if (i === strat.cur) b.className = 'on'; b.onclick = () => stGo(i); box.appendChild(b); });\n    if (document.activeElement !== $('.st-nm')) $('.st-nm').value = S.nm;\n    $('.sb-t').textContent = 'Step ' + (strat.cur + 1) + '\/' + strat.steps.length + (S.nm ? ' \u00b7 ' + S.nm : '');\n    $('.sb-prev').disabled = strat.cur === 0; $('.sb-next').disabled = strat.cur >= strat.steps.length - 1;\n    const o = stSelItem(), ed = $('.st-edit'); ed.hidden = !o;\n    if (!o) return;\n    const wp = o.k === 'p' && strat.sel.part === 'wp' && o.p[strat.sel.i];\n    $('.st-eh').textContent = o.k === 'p' ? (o.tm ? 'Blue' : 'Red') + ' player ' + o.n + (wp ? ' \u00b7 path point ' + (strat.sel.i + 1) : '') : o.k === 'l' ? 'Shot line' : 'Note';\n    $('.st-txn').textContent = o.k === 'p' ? 'Name' : o.k === 'l' ? 'Label' : 'Text';\n    if (document.activeElement !== $('.st-tx')) $('.st-tx').value = o.k === 'p' ? o.nm : o.t;\n    $('.st-tx').maxLength = o.k === 'p' ? 14 : o.k === 'l' ? 30 : 60;\n    $('.st-numl').hidden = o.k !== 'p'; if (o.k === 'p') $('.st-num').value = o.n;\n    $('.st-dshl').hidden = !(o.k === 'l' || wp); $('.st-dshn').textContent = o.k === 'l' ? 'Dashed line' : 'Dashed leg to this point (crouch \/ slow)';\n    if (o.k === 'l') $('.st-dsh').checked = !!o.d; else if (wp) $('.st-dsh').checked = !!o.p[strat.sel.i][2];\n    $('.st-last').hidden = $('.st-clr').hidden = o.k !== 'p'; $('.st-rm').textContent = wp ? 'Delete point' : o.k === 'p' ? 'Delete player' : o.k === 'l' ? 'Delete line' : 'Delete note';\n    $$('.st-sw button').forEach(b => b.classList.toggle('sel', b.dataset.c === o.c));\n  }\n  const stDirty = () => { stRender(); stSave(); };\n  STC.forEach(c => { const b = document.createElement('button'); b.dataset.c = c; b.style.background = c; b.title = c; b.onclick = () => { const o = stSelItem(); if (!o) return; o.c = c; if (o.k === 'l') strat.lc = c; stUI(); stDirty(); }; $('.st-sw').appendChild(b); });\n  for (let i = 1; i <= 10; i++) { const op = document.createElement('option'); op.value = i; op.textContent = i; $('.st-num').appendChild(op); }\n  $('.st-num').onchange = e => { const o = stSelItem(); if (o && o.k === 'p') { o.n = +e.target.value; stUI(); stDirty(); } };\n  $('.st-tx').oninput = e => { const o = stSelItem(); if (!o) return; const v = e.target.value.replace(\/[<>]\/g, ''); if (o.k === 'p') o.nm = v.slice(0, 14); else o.t = v.slice(0, o.k === 'l' ? 30 : 60); stDirty(); };\n  $('.st-dsh').onchange = e => { const o = stSelItem(); if (!o) return; if (o.k === 'l') o.d = e.target.checked ? 1 : 0; else if (o.k === 'p' && strat.sel.part === 'wp' && o.p[strat.sel.i]) o.p[strat.sel.i][2] = e.target.checked ? 1 : 0; stDirty(); };\n  $('.st-last').onclick = () => { const o = stSelItem(); if (o && o.k === 'p' && o.p.length) { o.p.pop(); strat.sel = { id: o.id, part: 'tok' }; stUI(); stDirty(); } };\n  $('.st-clr').onclick = () => { const o = stSelItem(); if (o && o.k === 'p') { o.p = []; strat.sel = { id: o.id, part: 'tok' }; stUI(); stDirty(); } };\n  $('.st-rm').onclick = stRemove;\n  $$('.st-tools button').forEach(b => b.onclick = () => stTool(b.dataset.t));\n  $('.st-dash').onchange = e => { strat.dash = e.target.checked; };\n  $('.st-sr').onchange = e => { strat.show[0] = e.target.checked; stRender(); };\n  $('.st-sb').onchange = e => { strat.show[1] = e.target.checked; stRender(); };\n  $('.st-names').onchange = e => { strat.names = e.target.checked; stRender(); };\n  $('.st-nm').oninput = e => { stCur().nm = e.target.value.replace(\/[<>]\/g, '').slice(0, 40); $('.sb-t').textContent = 'Step ' + (strat.cur + 1) + '\/' + strat.steps.length + (stCur().nm ? ' \u00b7 ' + stCur().nm : ''); stSave(); };\n  const stClone = it => JSON.parse(JSON.stringify(it)).map(o => Object.assign(o, { id: stId++ }));\n  $('.st-new').onclick = () => {\n    if (strat.steps.length >= 30) { setHud('That is the maximum number of steps.'); return; }\n    const it = stClone(stCur().it.filter(o => o.k === 'p')); it.forEach(o => { if (o.p.length) { const q = o.p[o.p.length - 1]; o.x = q[0]; o.z = q[1]; } o.p = []; });\n    strat.steps.splice(strat.cur + 1, 0, { nm: 'Step ' + (strat.steps.length + 1), it }); stGo(strat.cur + 1); stSave();\n  };\n  $('.st-dup').onclick = () => {\n    if (strat.steps.length >= 30) { setHud('That is the maximum number of steps.'); return; }\n    strat.steps.splice(strat.cur + 1, 0, { nm: (stCur().nm + ' copy').slice(0, 40), it: stClone(stCur().it) }); stGo(strat.cur + 1); stSave();\n  };\n  $('.st-del').onclick = () => {\n    if (strat.steps.length < 2) { if (stCur().it.length && confirm('Clear this step?')) { stCur().it = []; stGo(0); stSave(); } return; }\n    if (!confirm('Delete step ' + (strat.cur + 1) + (stCur().nm ? ' \"' + stCur().nm + '\"' : '') + '?')) return;\n    strat.steps.splice(strat.cur, 1); stGo(Math.min(strat.cur, strat.steps.length - 1)); stSave();\n  };\n  $('.st-cstep').onclick = () => { if (stCur().it.length && confirm('Remove everything from this step?')) { stCur().it = []; strat.sel = null; stRender(); stUI(); stSave(); } };\n  $('.sb-prev').onclick = () => stGo(strat.cur - 1);\n  $('.sb-next').onclick = () => stGo(strat.cur + 1);\n  $('.st-copy').onclick = () => {\n    const s = JSON.stringify(stSer() || []);\n    if (navigator.clipboard) navigator.clipboard.writeText(s).then(() => setHud('Strategy copied to your clipboard.'), () => prompt('Copy this strategy:', s));\n    else prompt('Copy this strategy:', s);\n  };\n  $('.st-paste').onclick = () => {\n    const s = prompt('Paste the strategy code here:'); if (!s) return;\n    let a = null; try { a = JSON.parse(s); } catch (e) {}\n    if (!Array.isArray(a) || !a.length) { setHud('That strategy code is invalid.'); return; }\n    if (!stEmpty() && !confirm('Replace the current strategy with the pasted one?')) return;\n    stLoad(a); stSave(); setHud('Strategy pasted.');\n  };\n  $('.st-img').onclick = () => { \/\/ picture of the current view with the step name on top\n    try {\n      renderer.clear(); renderer.render(scene, camera);\n      const c = document.createElement('canvas'); c.width = canvas.width; c.height = canvas.height; const x = c.getContext('2d'); x.drawImage(canvas, 0, 0);\n      const hh = Math.max(34, Math.round(c.height * .07)); x.fillStyle = 'rgba(10,14,20,.88)'; x.fillRect(0, 0, c.width, hh);\n      x.fillStyle = '#fff'; x.font = 'bold ' + Math.round(hh * .5) + 'px system-ui,sans-serif'; x.textBaseline = 'middle'; x.fillText('Step ' + (strat.cur + 1) + '\/' + strat.steps.length + (stCur().nm ? ' \u00b7 ' + stCur().nm : ''), Math.round(hh * .4), hh \/ 2);\n      c.toBlob(b => { if (b && stlSave(b, stlBase() + '-step' + (strat.cur + 1) + '.png')) setHud('\ud83d\udcf7 Saved a picture of this step.'); });\n    } catch (e) { setHud('Saving a picture is not possible here.'); }\n  };\n  document.addEventListener('keydown', e => {\n    if (mode !== 'strat' || fp.on || e.ctrlKey || e.metaKey || e.altKey || typing(e.target)) return;\n    if (e.key === 'ArrowLeft') { stGo(strat.cur - 1); e.preventDefault(); }\n    else if (e.key === 'ArrowRight') { stGo(strat.cur + 1); e.preventDefault(); }\n    else if (e.key === 'Delete' || e.key === 'Backspace') { if (stSelItem()) { stRemove(); e.preventDefault(); } }\n    else if (e.key === 'Escape') { stTool('sel'); stSelect(null); }\n  });\n\n  \/* ---------- start ---------- *\/\n  let lastT = 0;\n  function frame(t) {\n    requestAnimationFrame(frame);\n    const dt = Math.min(.05, (t - (lastT || t)) \/ 1000); lastT = t;\n    gpPoll(dt); mouseSync();\n    wsUpdate(); lblSync();\n    if (sb.on) { sb.t -= dt; if (sb.t <= 0) { sb.t = .25; sbRender(); } }\n    if (fp.on) walkStep(dt); else updateCam();\n    updateAmmo();\n    updateBalls(dt); updateNet(dt); updateMatch(dt); updateFlag(); flagUI(dt); watchUpdate(dt);\n    if (quiz.on && quiz.type === 'name' && quiz.target && !quiz.locked) glow(quiz.target, 0xff7a1a, .35 + .35 * Math.sin(t * .006));\n    if (hallG.userData.roof) hallG.userData.roof.visible = camera.position.y < HALL_H - .1; \/\/ no roof in the way when looking from above\n    renderer.clear();\n    renderer.render(scene, camera);\n    if (fp.on) { syncGun(dt); renderer.clearDepth(); renderer.render(gunScene, camera); }\n  }\n  buildField(); renderTools();\n  let saved = null; try { saved = localStorage.getItem(KEY); } catch (e) {}\n  if (!(saved && load(saved))) { buildField(); const dm = PREMADE.find(m => m.name === DEFAULT_MAP) || PREMADE[0]; if (!load(JSON.stringify(dm.data))) generate(); view('iso'); } \/\/ first visit: the DSL 2026 map\n  applyImgUI();\n  try { const s = JSON.parse(localStorage.getItem(IMGKEY) || 'null'); if (s && s.d) setImage(s.d, s.r || 0, null, s.fx, s.fy); } catch (e) {}\n\n  \/* ---------- settings window, help menu and the guided tour ---------- *\/\n  const dlg = $('.pbf-dlg'), hm = $('.pbf-help'), trEl = $('.pbf-tour');\n  function dlgTab(t) { $$('[data-dt]').forEach(b => b.classList.toggle('on', b.dataset.dt === t)); $$('[data-dp]').forEach(p => { p.hidden = p.dataset.dp !== t; }); }\n  function helpClose() { hm.hidden = true; }\n  function dlgOpen(t) { if (fp.on) exitWalk(); helpClose(); dlg.hidden = false; dlgTab(t || 'set'); $('.dlg-x').focus(); }\n  function dlgClose() { if (dlg.hidden) return; dlg.hidden = true; try { $('.h-set').focus(); } catch (e) {} }\n  $('.h-set').onclick = () => dlg.hidden ? dlgOpen('set') : dlgClose();\n  $('.dlg-x').onclick = dlgClose;\n  dlg.addEventListener('mousedown', e => { if (e.target === dlg) dlgClose(); });\n  $$('[data-dt]').forEach(b => b.onclick = () => dlgTab(b.dataset.dt));\n  $('.h-help').onclick = e => { e.stopPropagation(); hm.hidden = !hm.hidden; };\n  document.addEventListener('click', e => { if (!hm.hidden && !e.target.closest('.pbf-help')) helpClose(); });\n  document.addEventListener('keydown', e => { \/\/ Esc closes the settings window \/ help menu (not while a key is being rebound)\n    if (e.key !== 'Escape' || T) return;\n    if (!dlg.hidden && !$('.kb-list button.wait')) { dlgClose(); e.stopPropagation(); }\n    else if (!hm.hidden) { helpClose(); e.stopPropagation(); }\n  }, true);\n  $$('[data-tour]').forEach(b => b.onclick = () => { helpClose(); if (b.dataset.tour === 'keys') dlgOpen('pad'); else tourStart(b.dataset.tour); });\n  $('.sd-how').onclick = () => tourStart(mode);\n  $('.tour-again').onclick = () => { dlgClose(); tourStart('full'); };\n\n  \/\/ The tour steps. m = mode to switch to, tab = side panel tab, el = the real element to highlight\n  const TS = {\n    welcome: { t: 'Welcome to SpeedSim \ud83d\udc4b', x: 'Learn fields, practise callouts, build layouts and plan tactics, all in one tool.<br><br>This quick tour shows where everything is. You can replay it at any time with the <b>?<\/b> button at the top right.', m: 'play' },\n    modes: { el: '.pbf-modes', m: 'play', t: 'Pick what you want to do', x: '<ul><li>\u25b6 <b>Play<\/b>: walk the field, shoot, play online<\/li><li>\ud83d\udee0 <b>Build<\/b>: design or edit a field<\/li><li>\ud83c\udfaf <b>Practice<\/b>: quiz yourself on bunker names<\/li><li>\ud83d\udccb <b>Strategy<\/b>: plan players, paths and shot lines<\/li><\/ul><br>The colour, the toolbar and the side panel change with the mode, so you only see what you need.' },\n    view: { el: '.bar-view', t: 'Change how you look at the field', x: '<ul><li><b>3D<\/b>: free camera. Drag to rotate, scroll to zoom, right mouse button or <kbd>Shift<\/kbd> + drag to move<\/li><li><b>Top<\/b>: plan view from above<\/li><li><b>Red view \/ Blue view<\/b>: look from a start zone<\/li><li><b>Show names<\/b>: display the bunker names<\/li><\/ul>' },\n    'play-side': { el: '.sd-tabs', m: 'play', tab: 'maps', t: 'Play: the side panel', x: '<ul><li>\ud83d\uddfa <b>Maps<\/b>: load a premade field or one you saved<\/li><li>\ud83d\udd2b <b>Weapon<\/b>: rifle or pistol, bullet colour<\/li><li>\ud83c\udf10 <b>Online<\/b>: host or join a game (up to 5 per team)<\/li><\/ul>' },\n    actions: { el: '.bar-act', m: 'play', t: 'Walk, spectate, fullscreen', x: '<ul><li>\ud83d\udeb6 <b>Walk<\/b>: first person. <kbd>W<\/kbd><kbd>A<\/kbd><kbd>S<\/kbd><kbd>D<\/kbd> to move, mouse to look, click to shoot, <kbd>Shift<\/kbd> run, <kbd>C<\/kbd> crouch, <kbd>R<\/kbd> reload. Press Walk again or <kbd>Esc<\/kbd> to stop.<\/li><li>\ud83d\udc41 <b>Spectator<\/b>: watch an online game and draw callouts for the teams<\/li><li>\u26f6 <b>Fullscreen<\/b>: the field fills your screen<\/li><\/ul>These are the default keys. You can change them in \u2699 Settings.' },\n    online: { el: '.p-net', m: 'play', tab: 'net', t: 'Play online', x: '<ul><li>Type your <b>name<\/b> and pick a team<\/li><li><b>Host a game<\/b> to get a room code to share, or type a friend\\'s code and press <b>Join<\/b><\/li><li>Tick <b>Public server<\/b> to show up in the server list<\/li><li>The host picks the game mode (free play, match, buzzer, flag) and presses Start<\/li><\/ul>' },\n    settings: { el: '.h-set', t: 'Settings', x: 'Mouse sensitivity, field of view, name size, touch controls, <b>weapon colours<\/b> (other players see them), and your keys and controller.' },\n    'build-side': { el: '.sd-tabs', m: 'build', tab: 'bunk', t: 'Build: work through the tabs', x: '<ul><li>\ud83e\uddf1 <b>Bunkers<\/b>: place, move, rename, colour and resize<\/li><li>\ud83d\udcd0 <b>Field<\/b>: size, indoor hall, floor colour and texture, auto-generate<\/li><li>\ud83d\udea9 <b>Zones<\/b>: the start zones<\/li><li>\ud83d\uddbc <b>Image<\/b>: put a photo or plan under the field<\/li><li>\ud83d\uddfa <b>Maps &amp; save<\/b>: save, download and share your field<\/li><li>\ud83d\udda8 <b>3D print<\/b>: export the bunkers as STL<\/li><\/ul>' },\n    'build-bunk': { el: '[data-sec=\"bunk\"]', m: 'build', tab: 'bunk', t: 'Place your first bunker', x: '<ul><li>Pick a <b>shape<\/b>, then tap the field to place it<\/li><li>Tap a bunker to select it. Drag to move it. Then rename it, change the colour or size, rotate or duplicate it<\/li><li><b>Mirror to other half<\/b> keeps the field symmetrical<\/li><\/ul>Shapes greyed out? The layout is locked: press <b>Layout locked<\/b> in the toolbar to unlock it. Give bunkers a name: Practice mode uses those names.', side: 'left' },\n    'build-lock': { el: '.f-lock', m: 'build', t: 'Lock your layout', x: 'When the field is finished, <b>lock<\/b> it so nothing moves by accident. You can still rename and recolour bunkers while it is locked.' },\n    'build-save': { el: '.p-maps', m: 'build', tab: 'maps', t: 'Save and share', x: '<ul><li>Give the field a name and <b>save<\/b> it in this browser<\/li><li><b>Download<\/b> a file, or <b>copy the JSON<\/b> to share a field and paste it on another device<\/li><li>Premade fields are at the top<\/li><\/ul>', side: 'left' },\n    quiz: { el: '.p-quiz', m: 'quiz', t: 'Practice the callouts', x: '<ul><li>Choose <b>Where is\u2026?<\/b> (click the bunker) or <b>What is this bunker called?<\/b><\/li><li>Press <b>Start<\/b> and keep your score<\/li><\/ul>It only asks about bunkers that have a custom name, so name them in <b>Build<\/b> first. Tip: learn the names with <b>Show names<\/b> on, from Red or Blue view.', side: 'left' },\n    strat: { el: '.p-stb', m: 'strat', tab: 'strat', t: 'Plan a strategy', x: '<ul><li>Place <b>Red<\/b> and <b>Blue<\/b> players, then use <b>Path<\/b> to tap their route point by point<\/li><li>Draw <b>shot lines<\/b> and add <b>notes<\/b><\/li><li><b>Next step<\/b> adds a new step, so one board can show a whole plan<\/li><li>Save a picture or copy the strategy to share it with your team<\/li><\/ul>', side: 'left' },\n    'strat-stage': { el: '.pbf-stage', m: 'strat', tab: 'strat', t: 'Show it to the team', x: 'The board is drawn right on the field. Use the arrows at the bottom (or the arrow keys) to step through the plan, one step at a time.' },\n    help: { el: '.h-help', t: 'That is it! \ud83c\udf89', x: 'Replay this tour or a tour of one mode from here at any time. Have fun!' }\n  };\n  const TOURS = {\n    full: ['welcome', 'modes', 'view', 'play-side', 'actions', 'settings', 'build-side', 'build-bunk', 'build-lock', 'quiz', 'strat', 'help'],\n    play: ['play-side', 'online', 'actions', 'view', 'settings'],\n    build: ['build-side', 'build-bunk', 'build-lock', 'build-save', 'view'],\n    quiz: ['quiz'],\n    strat: ['strat', 'strat-stage']\n  };\n  const TOUR_KEY = 'pbf_tour_v1';\n  let T = null;\n  function tourStart(name) {\n    if (T) return; if (fp.on) exitWalk(); dlgClose(); helpClose();\n    T = { steps: TOURS[name] || TOURS.full, i: 0, m0: mode, tabs: Object.assign({}, sdTab), strat: false };\n    trEl.hidden = false; tourShow();\n  }\n  function tourEnd() {\n    if (!T) return; const t = T; T = null; trEl.hidden = true;\n    try { localStorage.setItem(TOUR_KEY, '1'); } catch (e) {}\n    if (mode !== t.m0) setMode(t.m0);\n    Object.assign(sdTab, t.tabs); sideRender();\n    if (t.strat && t.m0 !== 'strat') view('iso');\n  }\n  function tourShow() {\n    const id = T.steps[T.i], st = TS[id], n = T.steps.length;\n    if (st.m && mode !== st.m && !(st.m === 'quiz' && match.on)) { setMode(st.m); if (st.m === 'strat') T.strat = true; }\n    if (st.tab) sideTab(st.tab);\n    $('.tr-n').textContent = 'Step ' + (T.i + 1) + ' of ' + n;\n    $('.tr-t').textContent = st.t; $('.tr-x').innerHTML = st.x;\n    $('.tr-dots').innerHTML = n > 1 && n <= 14 ? T.steps.map((_, k) => '<i' + (k === T.i ? ' class=\"on\"' : '') + '><\/i>').join('') : '';\n    $('.tr-back').hidden = T.i === 0; $('.tr-skip').hidden = T.i === n - 1;\n    $('.tr-next').textContent = T.i === n - 1 ? 'Done \u2713' : 'Next \u203a';\n    tourPlace(); $('.tr-next').focus({ preventScroll: true });\n    setTimeout(tourPlace, 120); setTimeout(tourPlace, 400);\n  }\n  function tourPlace() {\n    if (!T) return;\n    const st = TS[T.steps[T.i]], card = $('.tr-card'), spot = $('.tr-spot');\n    let el = st.el ? $(st.el) : null, r = el ? el.getBoundingClientRect() : null;\n    if (r && (r.width < 2 || r.height < 2)) { el = null; r = null; }\n    trEl.classList.toggle('nospot', !r);\n    card.classList.remove('mid'); card.style.left = card.style.top = '';\n    if (!r) { card.classList.add('mid'); return; }\n    const vw = innerWidth, vh = innerHeight, cw = card.offsetWidth, ch = card.offsetHeight, p = st.pad == null ? 6 : st.pad, small = vw <= 700;\n    if (r.top < 8 || r.bottom > vh - 8) { try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (e) {} r = el.getBoundingClientRect(); }\n    if (small && r.bottom > vh - ch - 14 && r.height < vh - ch - 30) { scrollBy(0, r.bottom - (vh - ch - 14) + 4); r = el.getBoundingClientRect(); }\n    spot.style.left = (r.left - p) + 'px'; spot.style.top = (r.top - p) + 'px'; spot.style.width = (r.width + 2 * p) + 'px'; spot.style.height = (r.height + 2 * p) + 'px';\n    if (small) return; \/\/ on a phone the card sits at the bottom (CSS)\n    const g = 14, fit = { below: r.bottom + p + g + ch <= vh - 8, above: r.top - p - g - ch >= 8, right: r.right + p + g + cw <= vw - 8, left: r.left - p - g - cw >= 8 };\n    const pick = (st.side ? [st.side, 'below', 'above', 'right', 'left'] : ['below', 'above', 'right', 'left']).find(k => fit[k]);\n    let x, y; const clx = v => Math.max(8, Math.min(vw - cw - 8, v)), cly = v => Math.max(8, Math.min(vh - ch - 8, v));\n    if (pick === 'below') { x = clx(r.left); y = r.bottom + p + g; }\n    else if (pick === 'above') { x = clx(r.left); y = r.top - p - g - ch; }\n    else if (pick === 'right') { x = r.right + p + g; y = cly(r.top); }\n    else if (pick === 'left') { x = r.left - p - g - cw; y = cly(r.top); }\n    else { x = (vw - cw) \/ 2; y = vh - ch - 16; }\n    card.style.left = x + 'px'; card.style.top = y + 'px';\n  }\n  $('.tr-next').onclick = () => { if (!T) return; if (T.i >= T.steps.length - 1) tourEnd(); else { T.i++; tourShow(); } };\n  $('.tr-back').onclick = () => { if (T && T.i > 0) { T.i--; tourShow(); } };\n  $('.tr-skip').onclick = tourEnd;\n  document.addEventListener('keydown', e => {\n    if (!T) return;\n    if (e.key === 'Escape') tourEnd(); else if (e.key === 'ArrowRight') $('.tr-next').click(); else if (e.key === 'ArrowLeft') $('.tr-back').click(); else return;\n    e.preventDefault(); e.stopPropagation();\n  }, true);\n  addEventListener('resize', () => { if (T) tourPlace(); });\n  addEventListener('scroll', () => { if (T) tourPlace(); }, true);\n  try { if (!localStorage.getItem(TOUR_KEY)) setTimeout(() => { if (!T && !document.hidden && !net.host && !net.guest && !match.on) tourStart('full'); }, 3500); } catch (e) {} \/\/ first visit: show the tour once, after the splash screen\n  fillZones(); refreshList(); setWeapon(weapon); setMode('play'); resize(); requestAnimationFrame(frame);\n})();\n<\/script>\n<script>\n(function(){ var sp=document.querySelector('#pbf-app .pbf-splash'); if(sp) setTimeout(function(){ if(sp.parentNode) sp.parentNode.removeChild(sp); }, 3200); })();\n<\/script>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>\u25b6PlayWalk &amp; shoot \ud83d\udee0BuildDesign fields \ud83c\udfafPracticeLearn callouts \ud83d\udccbStrategyPlan tactics \ud83d\udc65 \u2013 online ? \u2699 \u25b6 Quick tourAll four modes in one minute Play tour Build tour Practice tour Strategy tour \u2328 Keys &amp; controller View 3D Top Red view Blue view Show names \ud83d\udd13 Lock layout \ud83d\udeb6 Walk \ud83d\udc41 Spectator \u26f6 Fullscreen \u2039\u203a \u270f Draw [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_canvas","meta":{"footnotes":""},"class_list":["post-11","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/speedsim.nl\/index.php?rest_route=\/wp\/v2\/pages\/11","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/speedsim.nl\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/speedsim.nl\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/speedsim.nl\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/speedsim.nl\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=11"}],"version-history":[{"count":25,"href":"https:\/\/speedsim.nl\/index.php?rest_route=\/wp\/v2\/pages\/11\/revisions"}],"predecessor-version":[{"id":45,"href":"https:\/\/speedsim.nl\/index.php?rest_route=\/wp\/v2\/pages\/11\/revisions\/45"}],"wp:attachment":[{"href":"https:\/\/speedsim.nl\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=11"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}