Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

725 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Arjun's projects

Personal site hosted with GitHub Pages at https://ascoding11.github.io, the main address: every page's canonical and share links, the sitemap and robots.txt use it. Cloudflare Pages also serves the same files from this repo's main branch at https://ascoding11.com (every merge goes live on both in a minute or two). ads.txt sits at the root of the repo, so both addresses serve it at /ads.txt. Google sign-in only works on addresses listed in Firebase (project rumblekarts) → Authentication → Settings → Authorized domains, so ascoding11.com needs to be listed there too. Browsers keep saved data (settings, guest accounts, drafts) per address, so the two addresses don't share it.

  • showcase/: the landing page for ascoding11.com (a bare visit to that address, with nothing after it, is sent here by a small script at the top of index.html; ascoding11.github.io keeps the home page). One self-contained page: what the site is, a short tour (Docket and Prompted open with #sample), the Traffic panel, and the hard problems solved. Its "View the site" button goes to /#new (the home page with the Games card and feedback bar, see index.html). The Traffic numbers are pasted into the trafficData JSON tag in the page, in the format of the analytics dashboard's "Copy showcase numbers" button (anonymous totals only: no names, emails or visitor IDs); update them by pasting new numbers there. Counted in analytics as showcase.

  • index.html: the home page, with links to the Web Development Projects, Productivity and Engineering Projects pages. Above the footer, an "Open the Back Room →" button (#brOpen, a <button> rather than a link, so it isn't a crawlable link to the hidden folder) opens the Back Room on the page without the code, the same way the code does (reveal(); on a phone, the phone version); the row (.br-door) hides once the Back Room is showing. The code (triple-click the © in the footer) still works too. A share link, the home page address followed by # and the Back Room's folder name (ascoding11.com/#e63334287a3deaa3), opens the Back Room there without the code. It's meant for the showcase page; the folder name still isn't written in the home page's source, and without the link the code is still needed. The showcase uses a second link, ascoding11.com/#new: it leaves the home page as it is and adds a fourth card, Games (the cards go two by two on wide screens), whose links open the whole Back Room there without the code, with its Party, Online (chat) and Leaderboard panels; this happens on phones too (reveal(html, dir, true)), because the phone version has no Online panel. It also loads the Back Room's "Tell Arjun" feedback bar (feedback.js) at the top of the page straight away; its "Which game?" list starts with "The website, not a game" (game: "site") for notes about the rest of the site. This link does write the Back Room's folder name into the home page

  • projects/: the Web Development Projects page, with a full card for each game

  • productivity/: the Productivity page, with a full card for each tool

  • engineering/: the Engineering Projects page, with a full card for each engineering project

  • account/account.js: optional Google sign-in, added to every page with the shared top bar. Uses the rumblekarts Firebase project (same as the analytics and the Back Room games). Signed-in visitors get a row in Firestore members/{uid} (name, email, photo, first/last visit, page count and the analytics visitor IDs of their browsers), which the analytics dashboard shows under Signed-in visitors. The Back Room games reuse a signed-in account instead of switching to an anonymous one.

  • site.css: styles shared by the home, Web Development Projects and Productivity pages

  • hype-cycle-exchange/: Hype Cycle Exchange, a live classroom stock-market game (Firebase)

  • boxed/: Boxed, a competition math game with real AMC 10 and AIME problems: a Daily five, Practice, and live multiplayer games (Firebase, same project and Firestore rules as Hype Cycle Exchange; games are stored in games with kind: "math"). The page holds only contest names, problem numbers, official answers and a topic tag per problem (Algebra, Geometry, Number Theory, Counting & Probability); the problem text is loaded from the AoPS Wiki API while playing (it asks artofproblemsolving.com/wiki/api.php and wiki.artofproblemsolving.com/wiki/api.php at once (6-second limit), stops asking for the rest of the visit if both are blocked, and if a contest page can't be read it loads that problem's own page; it reads both the older page layout and newer MediaWiki layouts that wrap sections in <section>; failures are logged to the console as Boxed: AoPS Wiki load failed). If the wiki doesn't answer, AIME problems (not AMC 10) load from public Hugging Face datasets through datasets-server.huggingface.co (gneubig/aime-1983-2024 and di-zhang-fdu/AIME_1983_2024 up to 2024, AI-MO/aimo-validation-aime for 2022–2024, MathArena/aime_<year>_<I|II>, MathArena/aime_<year> and math-ai/aime<yy> for 2025 on); a row is only used when its answer matches Boxed's key, the LaTeX is drawn with KaTeX (loaded from cdnjs only when needed), [asy] diagrams are replaced with a link to AoPS, and full results are cached in localStorage as boxed.hf1.<contest>. When a problem can't be shown, the sheet links to it on LIVE by Po-Shen Loh first (liveURL: past-contests/amc10/<year><A|B> or aime/<year><I|II>, with the 2021 Fall contests as 2021C/2021D), then the AoPS Wiki; Boxed only links to LIVE and never loads its pages

  • e63334287a3deaa3/rumble-karts/: Rumble Karts (Back Room), a 3D kart-battle game for up to 12 players (Three.js, Firebase Realtime Database rooms under rooms/<code> with no prefix, so any invite code without a two-letter game prefix opens it; same project and rules as the other Back Room games). There is no Quick match: the menu's room code (a random word-number by default, or an invite's) is the room. Rooms hold up to 12 karts (MAX_ROOM): a full code says "Room is full"; the plain code lobby spreads to lobby-2 … lobby-6. Modes: Classic (most wrecks in 3 minutes), Gun Game (level up through all 14 weapons; the ladder is GG_LADDER) and Zone. Each player writes only their own node rooms/<code>/players/<uid> (n, c, x, z, h, s, st, hp, sh, tb, sp, gv, fz, gh, tn, d, sc, sm, sr, ts), 20 times a second while anything changes; other karts are drawn from a short history of those updates (applySnapshot/updateRemote, the same idea as netsmooth.js). There is no host: the round record rooms/<code>/round ({id, ends, st, m, w}) is written with transactions by whoever notices it has run out, and the round number picks the map, the special drops and the zone spots. Everything else goes through rooms/<code>/events/<push> (removed after 5 s): shots, throws and hits. The shooter's computer decides what a rocket, blaster, boomerang, buffalo or tornado hit and sends a hit event, but only the victim's own computer applies damage to its kart and reports the wreck with a kill event (which is what credits the attacker), so no computer can hurt a kart it doesn't own. Practice vs bots runs the same code offline with Net.send looping events straight back. Items come out of ? boxes by the weights in ITEMS (w, relative odds; tier sets the frame colour and tier 2–3 pickups are announced to the room); epic items drop on the map every 30 s and Arjun's Gandiva with 30 s left. Two named weapons: Nico's Cream Cannon (common, two shots per pickup: a blob of whipped cream lobbed in a short arc, projectile kind cream; a hit event carries cr, the seconds of slow-down; every computer paints goo on the kart named and the victim's own computer takes 15 damage, drops to 55% top speed for 3 s and splatters its screen for 2.5 s; the goo washes off over about four seconds) and The Angry Whip (legendary, weight 1, half as common as the Gandiva; one use). The whip is the only weapon with a two-step hit: wh is the wind-up everyone sees (0.4 s telegraph, the target glows red and gets a note; the target's computer counts the 0.4 s from when the wind-up reaches it, so lag can't shorten the warning), then the target's own computer decides whether the lasso caught it (alive, no spawn shield or Spikes shield, not a ghost, not already caught, still within reach of a living attacker) and answers whh (caught: every computer lifts that kart from the event's time stamp for 1.2 s with three cracks, then slams it down, and the target's computer wrecks its own kart with weapon whip, so the attacker gets the wreck like any other; if another computer hears of the wreck before its own lift has finished, it slams the kart down there and then) or whm (missed: the whip cracks at empty air). Bots use both with the same rules and will Warp or Ghost out of a wind-up if they happen to hold one. Kill-feed text for the two reads "got creamed by" and "got whipped by". Nothing is stored between sessions except the driver name, paint, last room, mode and sound setting in the browser; the Leaderboards get play time, wrecks and wins through stats.js. On touch screens it shows a stick and a Fire button; window.__rumble exposes the game's objects for debugging and tests.

  • e63334287a3deaa3/shell-crackers/: Shell Crackers (Back Room), a first-person egg shooter (Three.js, Firebase Realtime Database rooms under rooms/sc-…, same project and rules as Rumble Karts). Maps are block worlds (72 × 72 m; blocks are 1 m wide and 0.5 m tall, so stairs are rows of half-metre steps) built by the functions in MAPS (buildFarm, buildFactory, buildTown) from the tools above them: fill/clear, building (walls, floors, windows, doors, flat or gabled roofs), stairs, stairsDown, tunnel, and small props. The ground is 3 m thick so tunnels fit under it, and every call is also placed turned 180° about the centre so both halves match. Bots path over a navigation graph of every spot an egg can stand. The Training range (menu button under Practice) is its own map, buildRange with RANGE_MAP, kept out of MAPS so it never comes up in practice or online rotation: bullseye boards at 10/20/30/45/55 m, balloons that pop and come back, and unarmed dummies (DUMMY_ROUTES) that strafe or patrol and respawn after being cracked. Every gun is on keys 1–5 (6 is the pistol) and T resets the stats; the number keys stay fixed there, while T is the rebindable "Reset training stats" action in the Controls panel (keybinds.js) and the Pistol and Swap keys work on the range too. Magazines still need reloading but never run out, yolk bombs refill, and it has no clock, no rating and no database writes; its hit, accuracy and headshot counts live only in the open tab (Range in the page). On touch screens it adds a button for each gun and a Reset button (#tTrain) to the usual touch controls. Ranked rating: Elo from each match's finishing order, multiplied by 1.25^(real opponents − 1) (×1 for one real opponent, capped at ×3; bots-only practice ×0.4; first 10 matches move faster; leaving an online match after 45 s counts as last place). Ratings are stored at rooms/sc-ratings/players/<uid>, which the existing rules allow; each player's own browser writes their rating, so it isn't cheat-proof. Online, each egg writes its own players/<uid> node about 12 times a second while anything changed (position, aim, health, shots), twice more right after it stops changing (Net.wantSend, so others see it stop at once instead of carrying it on into the wall or floor it stopped at) and once a second otherwise; other eggs are dropped after 9 s without an update. The match lives in rooms/sc-<code>/round (set by a transaction on joining, replaced when it is 20 s past its end); the last egg to leave the room removes it (Net.dropRound, only when no other egg is in the room and only the match it played), so an unused code doesn't keep a stale node, and rematch starts a fresh match if there is none. The round is the only thing that decides the map: its map, and sm for a small arena (below). Every browser keeps the newest value its listener has had from the server (Net.cur) and builds exactly that (Net.takeRound, onMatch); whoever wrote the round and whoever has left since doesn't matter. When a party lands together, every browser races to start the round with its own random map; the join and rematch transactions run with applyLocally off so nobody acts on a losing guess, and the join never acts on its own transaction result, only on what the listener delivers. Joining waits up to 8 s for the round; if it still hasn't come, the menu says "Waiting for the room's match…" (the old code dropped you onto the menu's random map, where everyone else was invisible), tries to start the round again after 12 s more and gives up with a message after 15 s beyond that. Each egg's node also carries the map and arena it has built (mp, ar), and another egg is only drawn, hittable or heard while its node carries the same match id (m) and, when it says, the same mp/ar as yours, so eggs on different rounds or layouts never see or hit each other. With every send (Net.check) the browser rebuilds at once if the map it has built isn't the round's, and if most of the other eggs in the match report a different layout for 2 s it reads the round again. If the last egg out removes the round while someone it hadn't seen yet is still in the room, that egg puts the round back (takeRound), so a later joiner doesn't start a new one on another map. Small arenas: an online match that starts with 4 or fewer eggs (SMALL_MAX, counted by whoever starts the round: on joining a room or in rematch) has sm: 1, and every browser builds that map with its far wings closed off (smallArena, from the map's small entry in MAPS: the box that stays open, a wall of the map's own fence round it from the tunnels up, and its own spawns nearer the middle). Outside the box every open block is BARRIER, which eggs can't walk into but which is never drawn and lets shots and sight lines through, so nobody jumps over the wall from a roof; buildings in the wings stay as scenery. If the room reaches 5 eggs, or an egg on an older copy of the game (no ar in its node) joins, in the first 15 s of a small-arena match (SMALL_GROW_MS), the round is switched to the full map (a transaction that only clears sm), so a party arriving one by one isn't stuck in a small arena; after that the size only changes at the next match. A small arena is only chosen when every egg in the room sends ar. Practice, the training range and rooms of 5 or more always get the whole map, built exactly as before. After changing a map's small box or spawns, check that the console shows no spawn moved or spawn blocked warning for its small arena too (buildMap(i,1)). Frame cost: hills and trees are merged into four meshes (one per quarter round the map, so the ones behind you aren't drawn), each cloud into one, each egg's face into two and each gun into one geometry shared by every egg and the view model (mergeParts, colours as vertex colours), and particles and shell shards are instanced pools (instPool), so a busy practice match is about 60 draw calls instead of about 220. A slow frame is played in steps of at most 1/30 s (simulate; only frames longer than FRAME_CAP, 0.25 s, lose time), so a slow laptop no longer plays in slow motion below 20 frames a second. If frames stay under about 30 a second for 8 s of play even at autores.js's lowest resolution, and the player has never picked a graphics setting, the game switches itself to Fast (no shadows), says so in the feed and saves that; Pretty in Settings puts it back. Collision: an egg is a box 0.64 m wide and 1.45 m tall (PR, PH) against the blocks. Movement goes one axis at a time in steps under 0.2 m, steps up onto ledges of up to STEP (0.6 m) with head room, and falls are capped at 30 m/s (MAX_FALL), so normal movement never ends inside a block at any frame rate. If something does put an egg inside one, pushOut moves it out the shortest way that ends clear, passes through no other block and doesn't take the egg's middle through a wall: sideways, down from under a ceiling, or up only onto a top within a step. (The old code lifted it half a metre at a time until it was clear, which could launch it up to 12 m onto a roof or out of a building.) If there is no such way within 1.5 m, rescue puts it on the nearest spot an egg can stand. The webdriver-only test hook (window.__sc) exposes the movement code for the collision audit. Spawns: each map builder places them with spawn(x,z) (mirrored like everything else), and each must be a spot an egg fits (standV: something solid underneath and 1.5 m of air above; the egg's box fits inside that block column). A spawn that a map edit has blocked is moved to the nearest clear spot on the same level within 3 m, with a console warning (spawnSpot), instead of being lifted onto whatever covers it, and respawn never hatches an egg inside a block (it shoves it clear or uses the nearest standing spot). All current spawns are clear; after changing a map, check that the console shows no spawn moved or spawn blocked warning. Other eggs are drawn with netsmooth.js, carried on for at most 200 ms (REMOTE_AHEAD) when an update is late, and drawClamp keeps them in the map and out of blocks (pushed out the same way as above, and when carried on, no more than half a metre below or a quarter metre above where they were heading), so nobody is drawn, or shot at, inside a wall or a floor.

  • e63334287a3deaa3/index.html and e63334287a3deaa3/games.css: the Back Room page. Under the header, the What's new strip, the Party and Online panels, the games come in one <section aria-label="Games"> (leaderboard.js puts the Leaderboards right after it, and the home page's reveal() copies this first <section> into the home page, so everything the games need is inside it) with five sections, in this order: Puzzles (the Daily Puzzles card, then Deepword, Puffer Party, Trivium, Gridpop and 2048), Parkour (Spire, Turbo Trail, Escapade, Updraft, Tumble Trials, Asteroid Run, Rampline, Tidewick), Shooter / Action (Driftfall, Shell Crackers, Backrooms 1v1–4v4, Rumble Karts, Chromaflage, Odd One Out, Lokop), Multiplayer (the other online games: Slipstream, Boostball, Showdown, Coil) and Single player (Stackfall, Melon Merge, Hivefix). Each game is in exactly one section, the most specific one that fits, so an online puzzle game is under Puzzles and an online shooter under Shooter / Action. Each section is a <section class="gs gs-<name>"> (gs-puzzles, gs-parkour, gs-action, gs-multi, gs-solo) whose heading has an icon, a one-line subtitle and a count (.gs-n), and its own colour (--gs-c, set per section in games.css). Each game is a launcher card, <article class="project gc">: the screenshot (<a class="shot"> with preview.jpg and a genre badge, .gc-genre), the name in an <h3>, one short line (.gc-blurb, about 55 characters so it fits two lines), two or three chips (.gc-chips: the first, class="on", says how it's played online, such as "Online · up to 12"; only true things, such as Bots or Solo too), a <details class="gc-more"> whose "i" button shows the longer description and feature list over the card, and the Play button (.actions .btn.primary, with data-invite="<prefix>" for online games, which the home page's invite links look for). Some cards have class="project gc wide" (two columns wide, picture on the left) so every section's rows come out even with four across: Parkour has eight regular (two rows; Spire was wide until Tidewick made eight), Shooter / Action one wide (Driftfall, the newest, moved up from fourth to advertise it; Rumble Karts took its old spot and size) and six regular (two rows), Single player one wide (Stackfall) and two regular (one row), Multiplayer four regular, and Puzzles three wide (Deepword, Puffer Party, Trivium) and two regular under the Daily Puzzles card; if a section's count changes, add or remove wide so its rows still come out even. On phones (560 px and narrower) only cards that also have pw (Deepword, Driftfall) take a whole row; the others pair up two by two. The Daily Puzzles card (<article class="project gc gc-daily">, linking to daily/) takes a whole row at the top of Puzzles, picture on the left; a small script at the end of the Back Room page fills its #dailyToday line with today's progress (how many daily puzzles are done and the streak, read from the daily:<slug> records, daily-hub:days and deepword:days), and it stays hidden in the home page's copy, which doesn't run that script. The styles are in games.css, linked from inside the Games section rather than the <head>, so the home page's reveal() brings them along; they only build on site.css's colours and fonts and are scoped to .br-games, so the other pages that use site.css don't change. Four cards across on computers, three on small laptops, two on tablets and phones. To add a game, copy a card into the right section, update the section's game count (.gs-n) and the intro's game count, and give it its chips. A brand-new game can wear a small orange "New" pill inside its genre badge and a "New · …" chip (inline styles on Driftfall's card); take them off once it's no longer new. A new daily puzzle doesn't get a card: it goes in DAILY_GAMES on the Daily Puzzles page. The home page's game layer (and the phone page's frame) let the Daily Puzzles page open its games inside the same layer, and close it when a page links back to the Back Room itself. Under the games, a "Want more games, or have an idea?" note has a Tell Arjun button (data-feedback-open, which feedback.js handles).

  • e63334287a3deaa3/trivium/: Trivium (Back Room), a trivia game: a Daily ten, Quick play by category, Survival, a 60-second Blitz, and live contests (Firebase, same project and Firestore rules as Hype Cycle Exchange and Boxed; contests are stored in games with kind: "trivia"; the host's screen grades answers and writes scores, players only write their own answers; invite links go through the home page as #tv-<code>). The question bank is in e63334287a3deaa3/trivium/bank/, one file per category; each row is [difficulty 1–3, question, right answer, three wrong answers, fact]. Questions get a stable ID from a hash of their text, so editing a question's wording makes it a new question. To add a category, add a file and a <script> tag for it in its index.html (and a color in CAT_COLOR). Keep every question and fact in your own words, and avoid facts that go out of date (current office-holders, records likely to be broken).

  • e63334287a3deaa3/boostball/: Boostball (Back Room), rocket-car soccer like Rocket League, 1v1 to 3v3 with bots (Three.js, Firebase Realtime Database rooms under rooms/bb-…, public lobbies bb-pub-1…6, same project and rules as Rumble Karts; invite links #bb-<code>). Car and ball physics follow Rocket League's numbers (from RocketSim) scaled by UU; braking and coasting are BRAKE_ACC / COAST_ACC. Cars have their own, heavier gravity, CAR_GRAV (1.25 × the ball's GRAV, which the ball keeps, so ball flight and bounces are unchanged), so they hang in the air less and come down with some weight; the jump's kick and hold are raised by JUMP_K (1.1), so a full jump still reaches about 95% of its old height but lands about 13% sooner, and boosting straight up still out-pulls car gravity (net 6.25 against 9.5 u/s² before; jump plus boost reaches crossbar height in 1.21 s instead of 1.14 s). Every car use of gravity goes through CAR_GRAV: the air, the pull along walls and ramps, the sticky-force test, and the guess at other players' cars between updates (updateRemotes). A car's own recoil from the ball is worked out as if it were a quarter heavier (CAR_HEFT; what the ball gets, so every shot, is unchanged), soft car bumps push a little less (BUMP_SOFT), and a car landing on its body on the floor bounces off at BODY_BOUNCE.floor (.08, was .25) so it no longer skips. Landings show a drawn-only suspension squash (SQ_*, other players' cars too), a thud that grows with the landing speed, and for your own hard landings a small camera dip (scaled by Camera shake, off under reduced motion). Older cached pages still play with newer ones: each page runs its own car, and the gravity difference only changes the guess at a remote car between updates (55 ms apart), by centimetres. A ball resting on the roof can be carried (soft car–ball contacts have friction and a gentler extra push: CB_* and PUSH* constants; hard hits are unchanged). The match lives in rooms/bb-<code>/round (score, clock, last goal; tc is the team colour pair, which the host sets), players in players/<uid>, touches and pads in short-lived events. A goal plays a fireball in the scoring team's colour on every client (one Points cloud, fewer puffs on touch screens). Settings → Team colours picks blue/orange, green/purple, red/teal, yellow/violet or pink/cyan (each pair checked under simulated colour blindness); online everyone sees the host's pick. Settings and garage are saved only in the browser under boostball- keys (teams, marker, gfx, name, body, accent, …). Bots: practice offers Rookie, Pro and All-Star (DIFF); online, the host's browser runs Pro bots in the empty seats. Bots drive with the same controls and car as players (botThink only sets the car's inputs) and see the ball a reaction time late (react); the levels differ only in the numbers in DIFF (reaction, aim, steering, boost and pad use, flips, aerials, keeping). They line up behind the ball, shoot toward the post away from a defender, pick up boost pads on the way back when short, go round a ball rather than through it when on the wrong side, clear straight up the field from in front of their own net, and get themselves unstuck from corners and the back of a goal. Before a change to the bots, play old against new headlessly through window.__bb over a couple of hundred matches per level: single results swing a lot. Each match the bots get random names from BOT_NAMES (no two alike, never a human player's name in the room; the host gives a bot a new name if someone joins with its name). Online the host sends each bot's name as its position in BOT_NAMES (in its bh row), so add new names at the end and don't reorder or remove any. The game loop runs from animation frames; when they stall (the tab is hidden) a 250 ms timer keeps step ticking in 100 ms slices without drawing, so a hidden host's bots, ball and node keep going (browsers run that timer about once a second in a hidden tab, so others then get its updates once a second) instead of freezing until the room picks a new host. In automated browsers only (navigator.webdriver), window.__bb exposes the game for tests. Other players' cars (updateRemotes) are carried on from their last update along the curve they were turning on (tw, the turn rate worked out from successive updates), for up to 0.4 s when an update is late; the drawn car moves at its own speed and closes the gap to that estimate no faster than CORR_V, so a late update makes it glide rather than jump. A host running no bots sends no bh, and everyone then drops the bots it ran before.

  • e63334287a3deaa3/coil/: Coil (Back Room), a snake arena (one canvas, Firebase Realtime Database rooms under rooms/cl-…, public arenas cl-pub-1…6, same project and rules as Rumble Karts; invite links #cl-<code>). Rooms hold 10 people and the host (whoever has been in the room longest) runs bots to fill the arena to 12, sending them in its own node (bh positions, bs skins, names and bodies). Each browser judges only its own snakes' deaths. The game loop runs from animation frames; when they stall (the tab is hidden) a 250 ms timer steps the world in 50 ms slices so a hidden host's bots keep moving for everyone (its own snake stays put, as before; browsers run the timer about once a second in a hidden tab, so the bots then reach others once a second, smoothed by netsmooth.js).

  • e63334287a3deaa3/showdown/: Showdown (Back Room), no-limit Texas Hold'em for up to 8 players (Firebase Realtime Database rooms under rooms/sd-…, same project and rules as Rumble Karts; invite links go through the home page as #sd-<code>). Everything travels through each player's own node: the dealer (the player with the newest dealer number, or with none yet, whoever has been in the room longest) runs the table and publishes it as JSON in tb, and seals each player's hole cards to that player's key in hc (ECDH P-256 + AES-GCM via Web Crypto), so the database never holds readable hole cards; players write only their own action (ac), sit-out (sit), rebuy (rb) and chat (ch). The dealer's browser does see every card, so it isn't cheat-proof against a dealer with developer tools. If the dealer drops, the next player takes over from the last table it saw and refunds the hand in progress. Bots (Easy, Normal, Hard) fill empty seats and run in the dealer's browser. Play chips only; nothing is stored between sessions except practice stats in the browser. Other players' hole cards only ever go into tb (each seat's sh, hand name h and best five c, plus a "shows" line in the log) at the showdown: an all-in run-out deals the rest of the board with every hand still face down, and at the showdown hands are turned over in order (whoever bet or raised last on the final betting round first, else the first player after the button), and a hand that can't win or tie any pot it's in is mucked face down instead (its action l becomes Muck, the log says it mucks, and its cards are never published). Hands that end with everyone else folding are never shown.

  • e63334287a3deaa3/chromaflage/: Chromaflage (Back Room), paint-yourself hide and seek for up to 8 players (Three.js, Firebase Realtime Database rooms under rooms/cf-…, same project and rules as Rumble Karts; Quick match uses rooms/cf-pub-1 to cf-pub-4; invite links go through the home page as #cf-<code>). Hiders paint their own bodies in a paint studio (brush, an eyedropper that reads the true colour of whatever surface you click, fill, undo, glossiness) and pose to hide (crouch under tables, lie under beds, flatten, or stick to a wall with G or the Stick button and slide up, down and along it at any height, upright or sideways, until they jump to let go); seekers tag them with a blaster that fires a shot every 0.18 s (FIRE_GAP) for as long as the mouse button or the touch Tag button is held, with no reload pause: a miss costs one of 10 ink (INK_MAX), a hit costs nothing, and ink trickles back all the time at one shot every 2.5 s (INK_REGEN), so a seeker can keep firing at a hider who jumps about but blind spraying soon runs the tank dry and then gets a shot every 2.5 s. Fire rate and ink live only on the seeker's computer, which also decides hits (a ray against the hiders as it draws them) and sends a tag claim (tg) for the host to check, so changing them needs no change to the room data. Modes: Classic and Infection. Like Showdown, everything travels through each player's own node at rooms/cf-<code>/players/<uid>, because the shared rules expect n, c, x, z, h, ts there and refuse other paths (such as events) for new prefixes: position and pose (y, po, fl, p, and wl: 0, or 1 to 4 for the way the wall a hider is stuck to faces, with y its height), body (s shape, sz size, rf glossiness), paint (pv version and pd, the 256 × 256 body texture as a WebP data URL, or JPEG where the browser can't make WebP; about 1 KB for a plain colour and 10–20 KB for a detailed pattern, re-sent at most every 1.2 s and only when it changed, so late joiners read it straight from the node), the last shot (sh), squeak (ta) and tag claim (tg). The host (newest host number he, else whoever has been in the room longest) publishes the round as JSON in its own rs and checks tag claims loosely; if it leaves, the next player carries on from the last round it saw. Anything read from another player's node is untrusted (any player can make itself host with a high he): sanitizeRS rebuilds a received round state field by field (known phases, numbers clamped, player ids checked and lists capped, settings through cleanSet), remoteTarget keeps positions inside the map and angles finite, and scores are shown as numbers, so keep new round-state fields going through sanitizeRS and escape anything put in innerHTML. A tab in the background gets no animation frames, so a 1 s timer keeps its node's ts fresh and, for the host, keeps the round ticking. Bodies are modelled about 1.7 units tall and drawn at BODY (0.64) times their size setting, so Normal is about 1.08 m. The Doll is a soft, rounded figure (a big round head on a short neck, a plump body, thick arms and legs with rounded ends, two small eyes), each part turned on a lathe (latheGeo, dollParts) with the texture spread evenly up the profile and normals taken from the profile so there are no shading seams; the Block is a slim slab. Each body shape has a 256 × 256 texture atlas where every texel knows its 3D spot on the body, so brushes paint by distance in 3D (atlasOf, Paint). Keep each part's region of the atlas (the rect passed to latheGeo) when reshaping a body, or saved and synced paint jobs land on the wrong parts; the Doll was reshaped inside the same regions, so paint saved before that lands on roughly the same places. Maps are axis-aligned boxes plus round props, built by buildManor (Hollyhock Manor: 48 × 38 m, a party night in a country house, with an 8 m grand hall whose staircase climbs to a gallery, ten rooms under 4.2 m ceilings), buildMuseum (Marlowe Museum: 58 × 50 m, a 7 m lobby, a top-lit grand gallery with free-standing walls, six galleries, a gift shop, a café and the storeroom) buildRyokan (Kirinoya Inn: 50 × 40 m, a Japanese inn round a glass-roofed garden courtyard, with four tatami guest rooms, a banquet hall, a tea room, a futon store, the hot-spring bath, the kitchen and a games lounge), and six everyday places: buildSchool (Birchwood High: 44 × 32 m, a locker-lined corridor, two classrooms, a science lab, a library, an art room, the staff room, toilets and the PE store), buildOffice (Larkspur & Co: 48 × 34 m, a reception, an open-plan floor of desk pods, a meeting room, the manager's office, a kitchenette, a server room, a store, a lounge and toilets), buildMarket (Greenleaf Market: 50 × 36 m, a 4.2 m supermarket floor of aisles, produce tables, chillers, freezer islands, a bakery, a deli and six checkouts, a café, the bakery kitchen, a stockroom with pallet racking and a forklift, the staff room, an office, toilets and a cold room), buildHotel (Hotel Bellamy: 48 × 36 m, a marble lobby with columns, a piano and a reception desk, a bar with a pool table, a restaurant, and off a carpeted corridor five guest rooms, each with a bathroom whose bath you can climb into, plus housekeeping and the laundry), buildLeisure (Riverside Leisure Centre: 50 × 38 m, a 7 m sports hall with bleachers, hoops and a climbing wall, a gym, a dance studio, the reception and café, two changing rooms with lockers and showers, first aid, the equipment store, an office and the plant room) and buildFlats (Maple Court: 46 × 31 m, four flats off a landing, each with a living room and kitchen, a bedroom, a children's room with bunk beds, a study, a hall and a bathroom, plus the residents' laundry and the bike and bin store; the north flats are the south ones mirrored by flat()). The everyday maps share painters, everydayMats and the everyday furniture (desks, office and school chairs, lockers, gondola shelving, counters, divans and bunk beds, bathroom pieces, gym kit, bleachers, trolleys, pallets). Walls, floors and ceilings come from kit: wallX/wallZ build a wall as two half-thick slabs, one per room, each reaching 1 cm past the centre line so the two overlap and leave no seam to slip into, each from a side spec (w wallpaper, lo and lh lower panelling, h height, base, rail, crown, casing), with door openings and their casings; win, pic, flat and sconce hang windows (an evening view), framed pictures, signs and wall lights, and ag places furniture with its back to a wall. Wall slabs are marked wall, and trims, pictures and curtains are not solid, so a hider can still stick to the wall behind them. The Builder helpers are box, at for turned furniture, prop for round things from the shared geo() cache (baked with the boxes into one mesh per material, so a map is about one draw call per material), ceil for ceilings drawn from below only so the menu's view from above looks into the rooms, spot for places bots hide under tables, beds and the inn's futon cupboard, and lamp for point lights, of which only six spread-out ones are used, three on the Fast setting; the furniture helpers are in furn (tables, round tables with cloths, chairs, benches, sofas, beds, a grand piano, a fireplace, plants and palms, lamps and chandeliers, washing lines, curtains you can walk into, vending machines, plinths and mannequins, which make good decoys). The bots only walk the floor: the manor's gallery and the inn's raised entrance step are for players. Each map gives spawns, a seek start and an attract spot for the menu's doll. Boxes marked soft (cloth and leaves) block the view and the seeker bot's line of sight but not the blaster's pellets. The boxes are also the colliders, the bots' navigation grid, line of sight and the surfaces the eyedropper and bots sample; they are filed in a 2 m grid (buildGrid, solidsIn, rayMap) so rays and collisions stay quick. Every pattern is drawn on a canvas by the P painters (damask and striped wallpapers, wood panelling, marble, tatami, shoji and fusuma, rugs, portraits, landscapes and modern paintings, and so on), from a fixed seed so all players get the same map. Every room must keep a ceiling. To add a map, write a build… function and add it to MAPS, then check it the way the existing ones were checked: every spawn, the seeker start and every bot spot free and reachable from the seeker start, a ceiling and a floor everywhere, no ray escaping the shell, and no solid furniture cut into a wall. Bodies collide as their pose's footprint circles against the boxes (moveEnt): each move is cut into steps shorter than the body's radius and anything it runs into pushes it back out the face it came in through (pushOut), so nothing can be crossed in one frame; a box whose top is within a step of the feet (stepOf: 0.45 m standing, half the body's height crouching or lying) is climbed only when there is room to stand on it, otherwise it is solid (a chair seat over its legs, a bed frame under its quilt); a box whose bottom is within 1 cm of the top of the head doesn't block, so a body that just fits under a table fits; low furniture is solid down to the floor unless the gap under it is at least as tall as the smallest lying body (minGap, the Petite Block, about 0.15 m: Builder.finish adds an invisible collider under anything lower); a body that finds itself inside something (after growing, say) moves to the nearest free spot that isn't across a wall (freeSpot, wallBetween), and a move never ends on the far side of a wall. The practice seeker bot "sees" by rendering a small view of you with and without you and comparing the colours (perceive, delta E), so better paint really does keep you hidden; it sweeps its gaze up and down so it can spot hiders high on walls, and furniture blocks its view. Practice hider bots sometimes hang on walls or tuck under furniture. Online rooms follow a daily map rotation: dayMaps shuffles all the maps with the day number as the seed (days start at midnight US Eastern standard time, DAY_SHIFT) and keeps the first three, and the map is dayMaps(now)[floor(now / 15 min) % 3] (rotMap), worked out from the shared server clock (Net.now). The host publishes it as rs.map in the lobby and when it starts a round, so a round in progress finishes on its map and the next round or the lobby moves on; the lobby shows the map now and "Next map in m:ss" with the next map's name (rotText), and the menu names today's three. The host of a private room can still pick a single map instead of "Daily rotation" (the map round setting, -1 for the rotation); Practice has a picker of every map; Quick match and invite links are unchanged. Nothing is stored online between rounds; your name, body, last paint job and settings are saved in the browser under chroma-…. Parties can launch it (its row in GAMES in party.js). On touch screens the page adds touch to body, and the body.touch rules at the end of the stylesheet place the HUD, the lobby card and the paint studio for phones held upright or sideways (keep them clear of the stick and buttons when adding HUD pieces). The lobby card and the paint studio scroll an inner box (.lb-body, .st-body) instead of the card itself, because Chrome can leave a blank patch in the 3D view under a scrolling box with a solid background.

  • e63334287a3deaa3/backrooms-1v1/: Backrooms 1v1–4v4 (Back Room; the folder keeps its original backrooms-1v1 name), a third-person build-fight shooter in Level 0 of the Backrooms (Three.js). Practice is you against the Entity, a bot that runs only in your browser. Online rooms hold up to 8 players split into Red and Blue (1v1 to 4v4, uneven teams like 4v3 are allowed and never filled with bots) in the Firebase Realtime Database under rooms/br-… (same project and rules as Rumble Karts; invite links go through the home page as #br-<code>). Quick match waits in rooms/br-queue/players and pairs two people into a br-q… room for a 1v1. Each player writes only their own node players/<uid>: n, c, x, z, h and ts always (the shared rules expect that shape), plus height, pitch, health, shield, weapon, a dead flag, the match m and round rd the position belongs to, the team picked in the lobby t, kills this match k, join time j and mv, the newest map version the page can build (3); about 20 times a second during a round (15 in rooms of 5 or more) while something changed, and twice a second otherwise or while nothing has. The host is the live player who has been in the room longest (the next in line takes over when they leave; a newcomer only starts judging who the host is once the room's players and round have loaded, so a slow phone joining mid-match can't take over and reset it) and is the only one who changes round: {id, ends, seed, ns, ph, pt, to, rn, sr, sb, ro, dd, rw, mw, fm, rz, mv}, where id is the match, ph the phase (lobby, cd countdown, fight, over, end), pt when the phase ends, to the rounds to win (3, 5 or 10, host's choice), rn the round, sr/sb the team scores, ro the roster (uid:r;uid:b), dd who is out this round, rw/mw the round and match winners, fm a forfeit, rz the host's "Randomize teams" draw (each player then moves themself) and mv the map version for this match (see below). The host writes with transactions guarded on id, ph and rn, and keeps ends at the time of writing because the rules cap how far ahead it can be. Shots, builds and deaths go through events/<push> (deleted after 5 s): the shooter's rays pass through teammates and the shot lists only the enemies it hit; the victim applies damage only from someone on the other team of the same match, and reports its own death naming the last shooter, which gives the kill credit. A round ends when one team has nobody left: dead, or silent for 10 s (a closed tab removes its node at once). A team with nobody left in the room (disconnected, or silent for 20 s) forfeits the match. A host whose own tab has just frozen waits a moment for the queued updates before judging anyone. Players who join mid-match wait in the lobby or watch until the next one, and dead players watch a living teammate (click, or tap Next on a touch screen, for the next one). Maps: every round is a new, randomly generated Level 0, in practice and online, on a grid of 4 m cells with walls on the cell edges and posts on the corners (layoutV3, map version 3). Every map is built the same way, so they all feel like the same place: a 3 × 3 room round each spawn, then rooms of 2 × 2 to 2 × 4 cells dropped at random until about two fifths of the floor is rooms, and everything else one-cell-wide (4 m) corridor. Every wall is full height: no low partitions, stray wall slabs, open halls or pillar fields (the most a room gets is one column in the middle, about one room in three). The rooms and corridor cells are joined into a tree and then given a few loops (a second or third way into some rooms, the odd extra link between corridors, most dead ends opened onto something), with three rules: never two openings side by side in one room wall, never four corridor cells open as a square (corridors stay one cell wide), and never a short piece of wall left standing on its own. Where a room opens onto something else the opening is a doorway, with a 0.8 m jamb of wall (JAMB) each side wherever the wall carries on past it. Any straight line of more than six cells (V3_RUN, 24 m) gets a wall across it near the middle, and the corridor jogs round it. The lighting is the same everywhere: about one ceiling panel in twelve is dead (the floor under it a little darker), up to three flicker on their own, and most maps have one or two banks (a room or a stretch of corridor) that flicker together and dim the walls, floor and ceiling around them; there are no tinted wallpapers, damp carpets or special rooms. Then each spawn is cleared to an open, lit 3 × 3 area, anything that can't be reached on foot is opened up, and the layout is checked for fairness (fairV2: both teams' spawns about as far from the middle on foot, the two halves within about a third of each other's cover, no huge detour between the teams); one that fails is redrawn (about one in four), and if sixteen in a row fail (in practice never), the closest is made point-symmetric, which is fair by construction but can leave a few open patches along the middle. Checked on 600 generated maps of every size and roster: no low partitions or free-standing wall pieces, no 3 × 3 block of cells without a wall inside it outside the spawn areas, no straight run longer than six cells, every cell reachable, the same seed always gives the same map; the longest sightline on a map is 23–53 m (v2: 42–69 m). A v3 map has more walls, so its collision boxes are joined up along each wall line (solidsV3: walls and jambs that touch end to end become one box, and posts in the middle of a straight run are left out), which leaves fewer boxes than a v2 map (about 210 against 310) and less work per frame for movement and shots. Size depends on the number of players and a little chance: 11–13 cells across for a 1v1 (and practice), 12–14 for 3–4 players, 14–15 for 5–6, 15–16 for 7–8. Red spawns along the west edge and Blue along the east. Online, the layout of round rn comes from the match's seed and rn (roundSeed), and its size from that and the roster size, so every player builds the same rooms without anything new being sent; a short blackout covers the change between rounds. Older copies of the page (still open in someone's tab after an update) only know earlier layouts, which are kept unchanged in the page and checked to build exactly the same walls, pillars and lights for the same seed: the original maze (layoutV1: one per match from seed, 12, 14 or 16 cells by room size (ns), Red's and Blue's spawn areas cleared), and the previous random layout (layoutV2, map version 2: the floor split into areas furnished in different ways (twisty corridors, small rooms, open halls with stray wall slabs, pillar fields, office cubicles behind 1.25 m grey partitions you can see and jump over, solid blocks of wall), joined by doorways or with no wall at all, each lit its own way, sometimes with a different wallpaper or a special room). Each player's node says which maps their page can build (mv), and the host writes into a new match the newest version every player in the room has: mv: 3, mv: 2, or no mv, in which case everyone, new pages included, plays the original maze for that match, so a mixed room always agrees on the walls (an older page that joins in the middle of a newer match only watches, and may see the wrong walls until the next match). A host on a v2 page writes mv: 2 at most, and one on a v1 page never writes mv, so the same applies. The collision rules below hold for every layout: automated walks and jumps into walls, doorway jambs, partitions, big pillars and wall blocks, spawns for every roster shape and shots at every kind of solid, run on hundreds of generated layouts, found nothing passing through or stuck inside a wall. Builds can't be placed inside a solid block of wall. Keys and mouse buttons can be changed in the Controls panel (keybinds.js, saved as keybinds:backrooms-1v1; the Change keys button under Controls in the menu, and Controls on the pause screen): move, jump, shoot / place, aim (Shift and right click), the three guns, reload, the three build pieces (two keys each), the arrow keys that look around when the mouse can't be locked, and pause (P; Esc is fixed). Clicking to watch the next teammate stays on the left mouse button, and the mouse wheel still switches guns. On touch screens it has a move stick, drag-to-look, Fire/Place, Jump, Aim, Reload and pause buttons, with the weapon and build slots as buttons along the top; while you watch, those give way to a Next button, and the lobby, pause and results screens are plain buttons that fit a phone either way up. Each browser is trusted for its own health and hits, so it isn't cheat-proof. A test hook (window.__br) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/drop-zone/: Driftfall (Back Room; the folder is drop-zone), a third-person battle royale for up to 30 players on Gullrock Isle (Three.js; Firebase Realtime Database rooms under rooms/dz-…, same project and rules as Rumble Karts; invite links go through the home page as #dz-<code>; Quick match uses the public rooms dz-pub-1 to dz-pub-4, the fullest one still boarding first; online matches are people only, at least 2). Everyone rides the Drifter (an airship) across the island on a line that changes every match, jumps when they like (Space, or Jump on a phone; anyone still aboard drops automatically as it leaves the island), falls, opens a steerable glider (Space again, or automatically about 44 m up) and lands anywhere, on roofs too. Then it's loot, build and fight until one is left. Island: 240 × 240 m (it was 320 m until map version 2: the seven places were moved closer together as whole blocks on the 4 m grid, the buildings themselves are unchanged), about 40 s' walk across and 50 s corner to corner, built by buildWorld() from a few tools; each place is built inside grp(dx, dz, …) with its shift from the old layout in OFS, so the place code still reads in the old coordinates: house() (walls with doors and windows, a floor per 4 m level and a flat roof, both sitting between the walls rather than under them, zigzag ramp stairs from zigzag() that need a free cell at each end, a parapet, and a trim band round the outside between levels), wallRun, slab, staticRamp, props (crate, bale, car, fence, pine, oak, rock, bush). Seven places: Haybale Farm (barn with a loft and roof stairs, farmhouse, silo), Signal Point (a 20 m lattice tower with zigzag ramps), Pinecone Plaza (a two-level mall round an atrium, shops along the north side, car park), Spillway Dam (a 12 m dam wall with stair towers at both ends, a pump house), Moonbeam Motel (two wings of rooms, a balcony reached by a ramp, an empty pool), Starfall Crater (a ring of boulders round a fallen star, two sheds) and Timber Camp (log cabins and a sawmill shed), plus sheds along the tracks. Every floor and roof is at a multiple of 4 m on the same 4 m grid as builds. All static geometry is merged into 16 chunk meshes with vertex colours (one material; no textures except the builds', which are mipmapped and anisotropic, and no shadow maps), the ground is one mesh, chests, floor loot, each build kind, tracers and particles are one instanced mesh each, so a view with 30 players in the match measured 16–55 draw calls; each player is one mesh (the merged body, plus the glider when it's out), and everyone's legs are one instanced mesh and the held items one instanced mesh per kind (legIM, gunIM, filled by syncLimbs() just before each render from the models' leg and item Object3Ds, which only carry the pose), so 30 players close together cost about 36 draw calls instead of about 120 (legs and items are hidden past 70 m). Solid boxes sit in a spatial hash of 8 m cells (hash, rhash for ramps), which movement, groundAt and the ray walk (raycast, cell by cell) use instead of the whole list. Flicker fixes (map version 2): no two faces share a plane any more (floors and roofs used to share the walls' outer faces, partitions reached into the floor above, posts shared faces with slabs and railings, and the trim band was a solid slab over each upper floor that also hid the mall's atrium and the stair holes); the camera's near plane follows the view (0.08 m pulled in against a wall, 0.15 m on foot, 0.5 m in the air, 1 m on the Drifter) and the far plane is 800 m, so distant faces a few centimetres apart don't fight; the farm's stripes are 4 m wide to match the ground's 2 m vertex grid. Keep new boxes off each other's faces (stop a box 1–2 cm short, or let it pass through the other) rather than flush with them. Movement, building and shooting copy Backrooms 1v1–4v4: the same speed, jump, step, collision box and moveEntity/pushOut, the same wall/floor/ramp binds (Q/Z, F/X, C/E) and pieces on the 4 m grid (up to 8 levels), the same over-the-shoulder aim from the camera with rays from the muzzle, headshots and damage numbers. Builds cost 10 scrap (you start with 30); the Crowbar (V or 5, or an empty slot) gathers scrap from trees (14 a hit), rocks and walls (6–10), and loot has scrap bundles. Destruction: builds and most of the map break. Each weapon has its own structure damage sd per bullet (Pistol 24, SMG 12, Shotgun 13 a pellet with its close-range falloff, Rifle 22, Sniper 150; the Crowbar 50), scaled by rarity and distance like player damage. Builds have 150 (walls) or 140 health and darken in three stages. On the map, buildWorld() makes breakable pieces with brk(health, scrap, fn) (everything fn adds is one piece): each 4 m cell of a house wall per level (with its trim band and, on top, its parapet; 240), each 4 × 4 m cell of a floor or roof (200), partitions (150 a cell), trees (170), rocks (80 + 60 per metre of size), cars (260), the farm truck, crates, bales, fence sections, railings (in up to 4 m lengths), the motel's balcony, posts and sign, the sawmill's posts and roof and the log pile. The ground, the dam's wall, ribs and stair towers, the tower's posts and floors, the silo, the star, stairs and ground-level decorations never break, so the map stays playable. A piece is a range of vertices in the chunk meshes plus its boxes; breaking it squashes the vertices to a point (one small buffer update a frame, mpFlush, no extra draw calls) and takes its boxes out of the hash, so players, shots, builds and bots go through. Damage darkens a piece in three stages the same way. Boxes of pieces in a line (a wall's cells, a floor's row) are merged into one solid per unbroken stretch (runs()), so movement and rays check about as many boxes as before. mpLink() records which pieces touch and which touch the ground or something unbreakable; when a piece breaks, anything that has lost every path to the ground breaks too (a roof whose walls are gone), and chests and loot on it drop (mpBreak). The breaker gets twice the piece's crowbar scrap. Pieces are numbered in build order (1,186 of them), the same in every browser; a new match restores them (resetMap). Loot is decided by the match seed, so nobody has to send it: about 82% of the 40 chest spots have a chest each match (spawnLoot), and each floor spot has a 72% chance of an item (FLOOR_R odds); a chest gives a weapon (CHEST_R odds, better than the floor's) with its ammo, often a heal and sometimes scrap (chestLoot). Weapons (WEAP): Pistol, SMG, Shotgun, Rifle and Sniper, each with its own damage, fire rate, spread, range and falloff, magazine and reload, and ammo type (light, rifle, shells, heavy); rarity (Common to Legendary, grey/green/blue/purple/gold) adds 7% damage a step and trims the reload. Heals (HEALS): Bandage +25 health (2.5 s), Medkit to full (6 s), Shield Fizz +25 shield (2 s), Shield Jug +50 shield (4.5 s); shield caps at 100 and you walk slowly while using one. Four slots (1–4, the mouse wheel, or tapping the slots); ammo, scrap, heals and weapons are picked up by walking over them when there's room, and G (or the touch Open/Take/Swap button) opens chests and swaps the item in your hand for one on the floor. Knocked-out players drop everything. Storm (STORM, makeStorm): the circle (180 m radius, R0) covers the island at first, then closes in six phases toward seeded centres (first close 70 s after the Drifter sets off; the last closes to nothing about 4¾ minutes in), and damage outside (health only) grows from 1 to 15 a second. The Drifter's line starts and ends 230 m either side of a point within 37 m of the middle, so it's always over the sea at both ends. The minimap (tap it, or M, for the big map with place names) shows the circle, the next one (dashed), the Drifter's line and you. Rooms and matches: each player writes only their own node rooms/dz-<code>/players/<uid>: n, c, x, z, h, ts always (as the shared rules expect), plus y, p (pitch), hp, sh, st (state: l lobby, b on the Drifter, f falling, g gliding, a on the ground, d knocked out, o watching), w (what's in hand), m (the match), j (join time), k (knockouts), pl (placing), dm (damage), oc (chests they opened this match, dot-separated) and pk (loot they took, comma-separated, so a late joiner or a new host sees the right island), and ev/e0…ev/e23, a ring of their recent events (each with a number i that only goes up, the match m and a time t): s shots (muzzle, tracer ends, the new health of builds hit pd [key, hp] and of map pieces hit md [id, hp], hits on players hs), b builds, o chest opened, u item taken, dr item dropped, x knocked out (by whom, placing, the dropped items). Writes are update()s of only the fields that changed: about 12 a second while you're moving in a match (10 with 10+ others in the room, 8 with 20+), a heartbeat every second or two otherwise. Every node also carries mv, the map version (2 since the island shrank; a node without it is version 1). The host is the live player on the same map version who has been in the room longest; its node also carries ms, the match state ({id, ph: 'lobby'|'play'|'end', ce: countdown end, sd: seed, t0: when the Drifter sets off, ro: roster, nh: people at the start, wn: winner, we: when the results end, mv: map version}), and during a match br: {m: match, b: …}, the broken map pieces as a base64 bitset with trailing zeros dropped (about 200 characters with everything broken). Players apply the shooter's reported health for builds and pieces, the lowest anyone reports, but never more than one shot of that weapon could take; a late joiner, anyone who missed an event, and the next host take the rest from br. Builds aren't part of the late-join state (they never were), so there's no list of broken builds. Map versions: the host only counts people on its own version for the lobby, the countdown and the roster, and only those elect a host; a page that sees someone on a newer version says "This match uses a newer map, reload to join." with a Reload button and sits matches out; the host's lobby says how many people have an older copy. Pages from before map versions existed don't know any of this: they're simply left out of the new pages' matches. Next time the island changes, raise MAPV. A match starts when the host presses Start (with 2 or more people) or 60 s after a second person joins the lobby; a lone player can't start one; the Drifter line, the storm and the loot all come from sd, and the clock from t0 (server time), so everyone agrees without sending them. Bots are in Practice only (11 of them; online matches are people only, so no bot slots, no bot list in the host's node and no bot hand-over). They're marked BOT in tags, the feed and the results, and this browser runs them: they jump, glide to a chest, loot, fight, heal, run from the storm and now and then wall up when shot; they walk straight at things, going in and out of buildings by the nearest ground-floor door (routeTo), shoot through breakable cover (builds, walls, trees) at someone within 35 m, break through a breakable thing they're stuck against (up to ten tries), and leave loot they get stuck on alone for 25 s; they don't use stairs, so upstairs loot is for people. They're tuned to be beatable: a reaction of 1–1.6 s, loose aim (0.085–0.135 rad error, more until they've watched you a while), a little over half their weapon's fire rate and rare building; a rifle bot 20 m from someone standing still does about a quarter of the damage it used to. Hits: the shooter's browser decides what its shots hit (resolveShot) and lists hits in the shot event; the victim's browser applies them only after a sanity check (acceptHit: a real weapon, no more than one shot of it can do, from within 16 m of where the shooter really is, within the weapon's range, and not faster than it fires), and reports its own knockout, which credits the shooter; hits on bots go to the host. If the host leaves, the next in line carries on the same match from the last ms it saw, with the broken pieces it already has. Someone who joins mid-match waits in the lobby panel or watches (Watch the match), and drops in at the next one; a room holds 30 (MAX_ROOM). Other players and bots are drawn with netsmooth.js. Results: placing, knockouts, damage and time, with the room's final order when the match ends. Leaderboards hooks (online matches only, which always start with at least two people): add('matches', 1), add('wins', 1), max('kills', n) (most knockouts in a match) and min('place', n) (best placing; place is in stats.js's LOWER). Saved in the browser under driftfall: keys: name, col (your colour), sens, room; keys in keybinds:drop-zone. Touch: a move stick, drag-to-look, Fire (Place, Use or Hit as it fits), Jump, Aim, Reload, an Open/Take/Swap button that appears when something's in reach, and the Crowbar, four slots and three build pieces as buttons along the top; the map is top right. Practice (offline) is the same match against 11 bots with no database at all, on its own clock that stops while paused. A slow frame is played in steps of at most 1/20 s (up to a quarter of a second), since the Drifter and the storm run on the clock. Keeping it smooth (November 2026 audit): every shader is compiled while the page loads (warmShaders, with the hidden meshes shown for a moment; the storm wall's used to compile, and freeze a frame, the first time it closed in), and the minimap's island picture is drawn during the menu rather than on a match's first frame (it takes about 30 ms). Sounds have a budget (voice: 14 nodes per 1/20 s, then only loud ones, and your own hit, hurt and pick-up sounds always), so a big fight or a building coming down doesn't build hundreds of audio nodes in one frame. The HUD only writes a value to the page when it changes (hudSet), name tags reuse their vectors and only move when they move a pixel, banners restart their slide-in without forcing a layout, and the storm (stormAt), hitboxes, the camera and movement no longer make new objects every frame. The lobby list is only drawn while the lobby is showing (every update from anyone used to redraw it, four times a second, mid-match), player names go through the name filter once (cleanName keeps them), and an update with no new events doesn't sort the event ring, so taking in another player's update costs about a fifth of what it did with 30 in a room. Writes are unchanged (update()s of the changed fields, about 50 bytes each, 10–12 a second while moving), so it plays with pages from before the audit. Each browser is trusted for its own health and hits, so it isn't cheat-proof. A test hook (window.__dz) exists only when navigator.webdriver is set. Collision rules (keep them if you touch movement): you and the Entity are boxes 0.72 m wide and 1.95 m tall; map walls, posts, pillars (0.9 m and 1.5 m), office partitions (1.25 m tall, 12 cm thick: jump onto or over them) and built walls and floors are solid boxes, and ramps are walkable surfaces rather than solids (so a ramp can be jumped up through from underneath). Movement goes one axis at a time so you slide along walls; a move that would enter a solid is dropped unless it is a step of up to 0.6 m onto something with head room; and a frame that would move you more than 0.25 m (below about 25 fps; frames are capped at 50 ms) is split into shorter steps, so nothing as thin as a wall can be skipped over. You stand on anything your box overlaps and fall only once you are clear of it, and walking up a ramp into a floor stops you under it (if a floor dropped on you pushed you down below a ramp's surface, you can still walk back down the ramp, just not further up). The only way into a solid is a build landing on you (your own, the Entity's or another player's), and the game then shoves you out the shortest way (sideways out of a wall, up onto or down from under a floor) that ends inside the map without passing through any other wall or build on the way, rather than trapping you or letting you walk through it; the same push-out runs at spawn and on every step. Other players' drawn positions are clamped to the map and pushed out of solids too, so a corner cut between two updates, or a late update carried on, never draws (or lets you shoot) someone inside a wall. Shots are rays tested against every solid, ramp surface, the floor and the ceiling before players, so they can't pass through walls or builds.

  • e63334287a3deaa3/spire/: Spire (Back Room), an obby-style parkour race up a tower stacked from random sections (Three.js, Firebase Realtime Database rooms under rooms/tw-…, same project and rules as Rumble Karts; Quick match uses rooms/tw-pub-1 to tw-pub-4; invite links go through the home page as #tw-<code>; rooms hold up to 12). Everything that isn't drawing lives between the // ==CORE== and // ==/CORE== markers in the page: movement (PHYS, simStep), parts (upright boxes and cylinders, each optionally moving by line, spin or orbit, timed, crumbling, lethal, bouncy, a conveyor, a climbable truss, ice (on the ground you speed up at ICE_ACC and slow at ICE_DEC, so you slide), a launch pad (boost: landing or stepping on it sets your velocity to that vector, and the take-off speed that caps air control is measured again from it) or a wind zone (zone: 'wind' with a wind vector, usually timed: while it's on it carries you like a conveyor, in the air too)), the section library (MODS), towers (buildTower), the obby (buildObby, WORLDS) and the checking bot; it has no DOM or Three.js in it, so a test can run it on its own. Physics is a fixed 120 Hz step with the player as an upright cylinder; moving parts are placed by time alone, so every player who shares the round clock sees the same tower. A jump reaches 3.37 (3.67 counting the pop onto a ledge just above your feet) and you run at 7.5. In the air the stick only nudges you (airControl): measured against the way you're flying, it speeds you up (AIR_ACC, but never past your take-off speed, or the walk speed if that was slower), turns you (AIR_TURN) and slows you (AIR_BRAKE), so holding back stops a running jump instead of turning you round, and holding sideways for a whole jump moves you about 2.3; from a standing jump the stick gets the full AIR_ACC. Sections keep the step from one route part to the one after next above 3.67 (a big jump can't skip a part), keep beams longer than a running jump, and make hazards too tall or too fast to simply hop. When changing a section, also try running jumps (with the real simStep) from each part of its route to every later one, to make sure none can be skipped. A section can offer a second way through: Fork in the Road has a slow lift and a gold shortcut (a narrow beam through a laser, a truss and two pillars, with a SHORTCUT sign, from a part's sign); its build takes a fourth argument alt that routes the bot through the shortcut, and verifyModule(id, d, mir, t0, seed, alt) checks that way too. Each section in MODS is built in its own frame: it starts on the 4 × 4 pad centred at (0, 0, 8) and must end with B.exit(H), its exit pad at (0, H, -8), staying inside radius 12.5 and above y = 0. Towers are versioned so everyone in a room builds the same one: towerPlan(seed, n, easy, v) with v 1 is the original 19 sections in the original order (kept exactly, for a host on an older copy of the page), and v 2 (TOWER_V) adds Gust Gaps, Ice Steps, Launch Pads and Fork in the Road (marked v: 2 in MODS) and climbs steadily: each slot takes the unused section whose difficulty is nearest a target rising from 1.4 to 4 (with a little chance mixed in), and the tightness d rises from about 0.12 to 0.72. Towers turn every other section 180° and may mirror any, and between sections there is a porch, a truss and a landing 5.5 higher (RISE), more than a jump plus a body (5.37), so nothing you can stand on in one section lets you reach the next one's low parts. Every section also lists a route for the bot (B.j, B.w, B.jon): before each jump the bot simulates it with the real simStep and only jumps when it lands (in the air it asks for just the speed that brings it down over the target, aimAir, as a player who commits to a jump would), and it waits or hops past hazards, so a route that completes proves the section can be beaten with the real movement numbers. Each landing between sections is also checkpoint k (cp on the part, its spot in W.cps[k]). Towers can have checkpoints (on your own, the Checkpoints switch on the menu, saved as spire:cpOn, on by default; online, the host's Checkpoints option, cp in the round): then reaching a landing saves your place (a mint landing, a fanfare and a mint mark on the height bar), anything lethal or R puts you back on it, and dropping more than 4 below it counts as a fall and does the same. Without checkpoints, red sends you to the bottom and a fall just lands you lower, as before. Either way the first time you reach each landing the feed shows your split time, and the fastest-tower best counts both (a clean run is just as quick either way). Coins float over two spots on each section's route (the same ones for everyone in a room, picked from the tower's seed): each one you pass through is one coin, picked up in the physics step. When adding or changing a section, run the bot over it at several difficulties, mirrored and not, and at several start times, and over whole towers (verifyModule, runBot; the page exposes them as window.__tw in automated browsers). Online, like Chromaflage, everything travels through each player's own node at rooms/tw-<code>/players/<uid>, which must always hold n, c, x, z, h, ts: position and animation (y, a), the round the player is in (r) and a finish claim (f, round:milliseconds). The host (newest host number he, else whoever has been in the room longest, chosen only once the room's players have loaded) publishes the round as JSON in its own rs: {id, seed, ph, t0, dur, n, ez, ff, rf, fin, ne, cp, v}, where seed builds the tower (with tower version v; a round without v is version 1 and one without cp has no checkpoints, so a host on an older page still works), t0 is the server time it starts, ff/rf mark the first finish (from then the clock runs at double speed) and fin is the finish order. Other players check the round before using it (cleanRS: finite times within a day of now, a finish list of [uid, ms, name], names cleaned), so a broken host can't freeze their page. The host takes finish claims at their word, so it isn't cheat-proof. If the host leaves, the next player carries on from the last round it saw. A tab in the background gets no animation frames, so a 1 s timer keeps its node's ts fresh and, for the host, keeps the round ticking. Nothing about a round is kept online after it ends. Saved only in the browser, under spire: keys: name, colour, hat (spire:hat), checkpoints on your own (spire:cpOn), settings, the host's round options (now with cp), and a profile (spire:profile: coins, XP, towers climbed, best times, falls, the furthest obby stage, best practice times and the hats bought with coins, hats), spire:awarded (the last online round that paid out, so rejoining it doesn't pay twice) and spire:climbed (set after the first truss climb; until then a tip shows at the foot of a truss). Hats (HATS): a cap, party hat, beanie, top hat, crown and traffic cone unlock with your level, and a halo, Viking helmet, propeller cap and wizard hat cost 100 to 500 coins (the menu's Hat row; a locked one says what it needs, or offers to buy it). Your hat travels online as k on your node. In a tower the sky runs on toward sunset as the camera climbs (skyAt); online a place pill beside the clock shows where you are (finishers in order, then everyone else by height), and the height bar has a strip in each section's colour and a gold ring on the leader. Climbers squash and stretch as they jump and land, and flying fast (a launch pad, a long fall) brings in speed lines and widens the view a little. Keys use keybinds.js (keybinds:spire). V (the view action) switches between the camera behind you and first person, as does scrolling all the way in or out; in first person your body moves to layer 1, which only the sun's shadow camera sees, so it's hidden but still casts its shadow, and spectating always uses the camera behind the climber you watch. The choice is saved as spire:view. On computers the mouse aims: clicking the game while playing captures it (Pointer Lock), so moving it turns the camera with no button held (0.0026 radians per pixel times Sensitivity, pitch kept to the same limits as before), and a crosshair replaces the cursor (a dot in first person). While aiming in third person the camera sits over your right shoulder and you turn to face where you aim as you move; Shift lock also keeps you facing it standing still. Esc, the pause menu, results, the Controls panel, the menus and spectating all let go of the mouse (only Esc and losing it some other way bring up the pause menu), and a small "Click to aim" shows while it's free during play; you can still move then. If the browser refuses the lock three times running (Chrome refuses a request within about a second of Esc), the hint goes and the old drag-to-look carries on, but every click still tries again. Settings and the pause menu have Sensitivity (spire:sens, shared with touch) and Invert Y (spire:invY, also for touch, off by default). On touch screens the page adds touch to body for a move stick, a Jump button, an eye button for first person, drag to look and pinch to zoom, with the Controls buttons hidden. Drawing: parts that never change are merged into one mesh per look and per four sections (vertex colours; the bevelled edges and side shading come from a per-face fe attribute that boxGeo/cylGeo add), while moving, blinking and crumbling parts and checkpoints keep their own meshes; lethal parts are unlit neon with a glow. Scenery (hills, trees, mountains, clouds, the obby's islands) is one mesh each, the sky is one shader, and dust, sparkles and confetti are two point buffers. Graphics Pretty (spire:gfx high, the default on computers) adds soft sun shadows that follow you, a second cloud layer and more particles; Fast (low) is the default on phones. Parties can launch it (its row in GAMES in party.js, { k: 'tw', n: 'Spire', d: 'spire', max: 12, nk: 'spire:name' }), the Online panel offers room invites to it, and it's in the feedback box's game list. It was archived for a while (October 2026) and brought back with checkpoints, the new sections, coins and hats.

  • e63334287a3deaa3/turbo-trail/: Turbo Trail (Back Room), a speedrun obby: 25 short levels (about 6 to 12 seconds each at gold pace) in five worlds of five, Candy Coast, Neon City, Lava Forge, Frost Peaks and Star Drift, each with its own sky, sea, palette and scenery (Three.js, Firebase Realtime Database race rooms under rooms/sr-…, same project and rules as Rumble Karts; Quick match uses rooms/sr-pub-1 to sr-pub-4; invite links go through the home page as #sr-<code>; rooms hold up to 12). Everything that isn't drawing lives between the // ==CORE== and // ==/CORE== markers in the page, with no DOM or Three.js in it, so a Node script can run it on its own: movement (PHYS, simStep), parts (upright boxes and cylinders, optionally moving by line, spin or orbit, blinking (timed), crumbling, lethal, a jump pad (bounce), a climbing wall (wj), ice, or a zone: speed pads, lowg low gravity and the portal), the level builder (Course), the levels (WORLDS, LEVELS, buildLevel) and the checking bot. Physics is a fixed 120 Hz step with the runner as an upright cylinder (radius 0.42, height 2), so it plays the same at any frame rate (a slow frame runs several steps; nothing moves more than 0.5 in one step, and the thinnest solid or lethal part is 0.25), and parts are found through an x/z grid of 10 × 10 cells. Running speed is 12.5 with snappy acceleration, a jump rises 3.24 and carries about 9 at full speed, with 0.12 s of coyote time and a 0.14 s jump buffer; in the air the stick speeds you up, turns you and brakes you (airControl) but never past your take-off speed. Speed pads push you to 27 along their arrows (the extra speed fades on the ground in about a second and stays in the air), jump pads throw you 7.8 up, and touching a striped climbing wall in the air lets you jump again off it (up 16.5, 8 away from the wall, keeping your speed along it; you also slide down it slowly). Levels are written with the Course cursor (start, run, gap, stones, blocks, pad, bounce, bars, stairs, movers, pistons, spinner, lasers, wallrun, chimney, crumble, blinks, slalom, lowg, orbit, drop, turn, portal); each piece also adds the checking bot's route. The bot plays with the real simStep: before each jump it simulates the jump and only takes it if it lands, it hops or waits for hazards, and it pushes off climbing walls; when changing a level, run the bot over it at several start times (moving parts depend on the clock) and at the top upgrade speed (runBot(buildLevel(i), t0, 90, run)). The medal times in MEDALS ([gold, silver, bronze] per level) come from the bot: gold is its median time over several start times plus 4% and 0.25 s, rounded up to a quarter second, silver is 1.3 × gold and bronze 1.75 × gold, so they need re-working out if a level changes. A level's clock starts with your first move and a fall, a lethal part or R puts you back at the level's start with the clock reset; in a Full run (all 25 on one clock, starting with your first move on 1-1) the clock keeps running through falls and T starts the run over. After each portal the split (your full-run time there against your best run's, or the level time against your best) shows green when ahead and red when behind. Stars: 1, 2 or 3 for a bronze, silver or gold best; they unlock speed upgrades (SPEEDS: +4%, +8%, +12% at 12, 30 and 50 stars, chosen in the Garage and used online too) and trails (TRAILS, at 3 to 75 stars; a camera-facing ribbon of the last 0.45 s, one draw call). Saved only in the browser under turbotrail: keys: name (a plain string, which party.js puts back after a party launch), color, settings (sens, invY, follow, ghost, vol, mus, gfx, zoom, slock, hostC, room), prog ({reached, best: [25 times], run: {t, sp: [25 splits]}, speed, trail, clears, falls, runs}), ghost:<level> (your best run on that level, 20 samples a second as comma-separated whole tenths of x, y, z and hundredths of the facing, replayed as a see-through runner when Best ghost is on) and awarded (the last race win counted). Online, like Spire, everything travels through each runner's own node rooms/sr-<code>/players/<uid>, which must always hold n, c, x, z, h, ts: also y, a (animation, 5 while respawning), l (which level of the course you're on), r (the race the position belongs to), tr (trail), j (join time) and f (a finish claim, race:milliseconds). The host (newest host number he, else whoever has been in the room longest, chosen once the room has loaded) publishes the race as JSON in its own rs: {id, ph: 'race' | 'inter', t0, cs, nm, fin, ff, ne}, where cs is the course (a random world's five levels, five from all over, or all 25: the host's Course option in the pause menu, saved as turbotrail:hostC), t0 the server time it starts (a 5 s countdown), fin the finish order [uid, ms, name] and ff the first finish; the race ends when everyone live has finished, 30 s after the first finish, or after 45 s a level plus 20 s, then results show for 9 s and the next race starts. Guests clean the host's race before using it (cleanRS), so a broken host can't freeze them. The race clock is the simulation clock, so every runner sees the moving parts in the same places; other runners are see-through ghosts (with their trails) drawn from a short history of their updates with ../netsmooth.js, only while they're on the same level as you, and a list on the right shows everyone's progress. The host takes finish claims at their word, so it isn't cheat-proof. A background tab keeps its node fresh (and, for the host, the race moving) with a 1 s timer. Camera: behind you, pulled in so it never ends up inside a wall, easing round to the way you're running while you hold forward (not while strafing, and not for 0.7 s after you turn it yourself; Settings → Camera: Free turns that off), and a little wider at high speed. On computers clicking the game captures the mouse (Pointer Lock) to look around; Esc frees it. Keys are rebindable with keybinds.js (keybinds:turbo-trail). On touch screens the page adds touch to body: a run stick, Jump, a restart button, and drag anywhere else to look; Fast graphics by default. Drawing: parts that never change are merged into one mesh per look (bevelled edges from a per-face fe attribute, as in Spire), moving, blinking and crumbling parts keep their own meshes, scenery is one mesh per level, the sky one shader and particles one point buffer, so a level is about 20 to 30 draw calls; Pretty graphics adds sun shadows. Leaderboards hooks: BackRoomStats.min('obby', seconds) for a finished full run (obby is already in stats.js's LOWER list; stats are kept per game folder, so it doesn't mix with Spire's), max('stars', total), add('levels', 1) at every portal and add('wins', 1) for winning a race against at least one other runner. Ads follow the AD_SLOT pattern (empty). A test hook (window.__sr, with advance(seconds), autopilot(on) and buildLevel/runBot) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/escapade/: Escapade (Back Room), a 3D escape-room obby (Three.js, Firebase Realtime Database rooms under rooms/er-…, same project and rules as Rumble Karts; Quick match uses rooms/er-pub-1 to er-pub-4; invite links go through the home page as #er-<code>; rooms hold up to 12). Worlds: six worlds of eight levels (48), each level a themed set of rooms with one way out, harder world by world. World 1, Breakout (Detention, Lab Rats, Hot Kitchen, Toy Box, Down the Drain, The Vault, Orbit Station, The Warden), is built into index.html, unchanged. World 2, Lost and Found (Overdue (a library), Clockwork (a clock tower), Cold Feet (an ice cave), Lost Temple, Mirror Manor (a haunted house), Stage Fright (a theatre), Gale Point (a lighthouse in a storm) and After Hours (a museum, the world's finale)), is escapade/worlds/w2.js; its first five were levels 9–13 before worlds came, with the same ids. World 3, Deep Down (Cave-In (an old mine), Deep End (a flooded cave), Off the Rails (an underground railway), High Water (a pump station), Fish Tank (an aquarium), Crystal Clear (a crystal cavern), Hull Breach (a sunken ship) and Hot Core (a volcano, the world's finale)), is escapade/worlds/w3.js; it added water you swim in (jump strokes up; an Air bar while your head is under), minecarts on rails (rail), water and lava that rise (rise, started by a flag or a trigger zone) and dark rooms you need the lantern for (B.dark, item lamp). World 4, Full Power (Assembly Line (a toy factory), Sorting Day (a recycling plant), Spin Cycle (a power station), Growing Pains (a greenhouse), Hot Swap (a data centre), Night Shift (an observatory), Spare Parts (a robot lab) and Meltdown (a power plant, the finale)), is escapade/worlds/w4.js; it added belts and fans a lever turns round (rev), pressure plates you stand on (B.hold), presses and piston walls that crush (B.crusher, B.press) and red, green and yellow keycards with their doors (B.cardDoor). World 5, Sky High (Hot Air (a balloon festival), Big Top (a flying circus), Thunderclap (a skyscraper in a storm), Sky Deck (a revolving restaurant), Zeppelin (an airship), Cloud Keep (a castle in the clouds), Orbiter (a satellite) and Eye of the Storm (a weather station in a hurricane, the finale)), is escapade/worlds/w5.js; it added updrafts (B.lift), cannons you fire yourself from (B.cannon, a {w5: 'cannon'} button), lightning on a timer with rods that power gates (B.bolt), groups of parts that turn a step at a press (B.turning, B.turnBtn, B.align) and the clouds scenery. World 6, The Last Door (One Way Home (a labyrinth with one-way flaps), Sun and Sand (a pyramid), Siege Works (a fortress), Dragon's Wake (a dragon's keep), Borrowed Time (a vault of clocks), Upside Dream (a dream), Long Climb (a climb over a molten sea) and The Last Door (the Keeper, a three-phase boss, and the end of the game)), is escapade/worlds/w6.js; it added one-way flaps (oneway: [nx, nz], B.oneway), rings of fire you jump (shape ring, move pulse, B.ring) and the shadow (B.shadow zones: a chaser that walks your own path a few seconds behind you); escaping its last level shows "You beat Escapade!". Each of Worlds 3–6 keeps its shared code in its own marked blocks in index.html (// ---- World <n> core additions ---- inside the CORE, // ---- World <n> drawing additions ---- outside it) with one-line hooks ending // W<n> hook; each does nothing unless a level uses it, and worlds/README.md section 13 lists every helper. Worlds 3–6 are needs: 3 (CORE_VER 3). Each world after the first is its own file, worlds/w<n>.js, loaded only when it's needed (picked in the menus, the level an online room is playing, or the next world when you reach a world's last level), so the page stays small; a world file only describes levels (Escapade.world({n, needs, levels})), and every shared helper, puzzle part and the bot live in index.html. escapade/worlds/README.md is the guide for building a world: the API, every helper and puzzle with its parameters, seeds, checkpoints, stars, the bot route, the tests a level must pass, a performance budget, a difficulty guide and the mechanics already used. The WORLDS list in index.html (one line per world: name, colour, blurb and its eight level ids) is what the menus show before a file loads and what progress counts by. CORE_VER goes up when the page gains something world files can use; a file says which one it needs, and a page too old for it (or one that can't load it) shows an "Update needed, reload" notice instead of breaking. Everything that isn't drawing lives between the // ==CORE== and // ==/CORE== markers: movement (PHYS, simStep, Spire's numbers: jump 3.37, 3.67 with the ledge pop, run 7.5, fixed 120 Hz step, so the frame rate never changes the physics), parts (upright boxes and cylinders, filed in a 6 × 6 xz grid for collision; moving by line, spin or orbit; timed, crumbling, lethal, bouncy, conveyors, ladders, low-gravity zones; and for levels 9–13: ice (slippery: you speed up and stop slowly, PHYS.ICE_ACC/ICE_DEC), the move types swing (a pendulum: platforms and blades that stay level, hanging len below a pivot) and roll (one way at a steady speed, then round again: the temple's boulder), ladders (trusses) that move and carry you while you hang on (stick off = hang, stick into it = climb), tp portals ([x, y, z, yaw]: where they send you; put the arrival point a little above the floor), zones cold (you freeze after PHYS.COLD seconds in it, shown as a Warmth bar) and warm (a fire thaws you), and slide crates (one shove and they slide until something stops them); and for World 2's last three: zones wind (wind: [vx, vz], blows you along on the ground and in the air, in gusts when timed; white streaks show it, fainter just before a gust; the bot leans into it), the lever button {toggle} (flips a flag on and off, for scenery that swaps with onIf/offIf) and B.all(flags, set) (a flag that comes on once all of its flags are: the museum's vault), plus level options sea (a molten sea to the horizon instead of the lawn) and msgs/unlock (a level's own feed messages and Unlocked! banners)), the puzzle parts and logicStep/useBtn/tryCode, the level library (LEVELS, each with a par target time and a build that also writes the checking bot's route) and the bot; it has no DOM or Three.js in it, so a Node test can run it on its own. Puzzles are flags in the level's state W.st.f: a part with door: 'flag' is solid until the flag is on (a number means "on until that time": timed gates); onIf/offIf make a part exist only while a flag is on/off (the space bridge, the Warden's arms); lock: 'item' opens a door when you come near carrying the item; item parts are pickups (key, card, fuse, gem); btn parts are used with E / the USE button: {set} (a button), {timed, dur} (a valve), {seq, sym} (recipe buttons, pressed in the order W.seqs[g].order), {pad} (a keypad; the code is W.codes[k], four digits from the level's seed), {need, then} (a fuse box), {dial, key, n} (turns one hand of the clock: W.dials[g] holds the target time, from the seed; all of a group's dials on target sets its flag), {mirror} (turns a mirror a quarter; beamTrace follows the moonbeam in W.beams[g] across the floor, and the beam reaching its target sets the flag); plate parts latch a pushed crate (push, kept inside lim, pushed along one axis at a time) and set their flag; step parts ({seq, sym}, the temple's stones) count when you land on them, in the order W.seqs[g].order (the mural shows it). The library's code is W.codes.lib, its digits shown on four coloured books in the order W.books.lib; the manor's right mirror is the colour W.rose (from the seed, like every other clue). Walls are 10 tall (22 in the low-gravity bay) so nothing standable gets you over them. Each level keeps the puzzle in its own copy for every player: there are no shared doors, so nobody can open or block anything for anyone else. Stars, saved in this browser under escapade:prof ({best: {levelId: seconds}, st: {levelId: bits}, esc, deaths}; bits 1 escaped, 2 beat par, 4 found the gem; the gem counts once found in any escaped run), by level id as always, so saves from before worlds came load as they were (8 escaped shows World 1 done and World 2 open; 13 shows World 2's first five done). The menus: Play carries on from the first level you haven't escaped; Worlds shows a card per world (name, stars of 24, how many escaped, locked or coming soon), then that world's eight levels. A level opens when you escape the one before it in its world; a world opens when you escape the last level of the one before it (or any of its own levels, say online). The menu counts escapes of 48 and stars of 144. Death (touching anything red and glowing, or falling) respawns you at your last checkpoint after 0.6 s; what you found and opened stays, crates not on their plate go home, crumbled tiles come back and timed gates shut (softReset). On your own the clock stops while paused or while the keypad is open. Online: each player writes only their own node rooms/er-<code>/players/<uid>: n, c, x, z, h, ts as the shared rules require, plus y, a (animation), j (join time), rr (the round you're playing), f (your finish, round:ms:server time) and, from the room's leader only, lr/ll/lt (round number, level, start time). The leader is the live player who joined first (Net.leader); everyone else follows the leader's round, loading that level with a seed made from the round (so codes and recipes match for everyone) and running moving parts from lt, so all players see the same moving platforms. Late arrivals' own clocks start when they arrive. When everyone in the room is out (6 s later), or two minutes after the first escape, the leader starts the next level; the leader can also pick a level from the pause menu. If the leader leaves, the next one carries on with the same round. Other players are drawn with ../netsmooth.js and only while they're in your round. Every value read from another node is checked (onNode). Each node also has v, its page's CORE_VER. The leader can pick any level in the worlds and levels it has opened itself (pause menu, Pick the room's level); everyone else loads that level's world file when they see the round (the round starts 4.5 s later, and a late loader's clock starts when it arrives), and a page that doesn't know the level shows the update notice. Pages from before worlds came (no v) only know levels 1–8 and simply don't follow a later level: the room list says they need to reload and the leader's feed names them. Keys are rebindable through keybinds.js (keybinds:escapade, actions forward, back, left, right, jump, use, shiftLock, view, zoomIn, zoomOut, reset). Touch: stick, JUMP, USE (green when something can be used), drag to look, pinch to zoom; shown only on touch screens. Other saved settings: escapade: name, color, room, sens, invY, vol, mus, gfx, view, zoom, slock. Leaderboards hooks: BackRoomStats.add('escapes', 1), max('cleared', n) (levels escaped, up to 48), max('stars', n) (up to 144), min('total', seconds) (World 1's best total: the sum of its eight best times once they all have one, as before worlds came, so totals already on the board still compare; the menu calls it "World 1 total") and min('total-w<n>', seconds) for each later world (total-w2 … total-w6 are in stats.js's LOWER; the board has a row for each world). Ads follow the AD_SLOT pattern (empty). A test hook (window.__er, with advance, frames, autopilot, depth, ensureWorld) exists only when navigator.webdriver is set. When changing a level, keep the route in its build working and re-run the bot on it at several start times, and check that every puzzle door is still needed: with one door kept shut and all the others open, running, jumping and running jumps from everywhere reachable must not reach the exit (the checks used for this branch ran exactly that on all eight levels; levels 9–13 were checked the same way, with the search keeping the whole player state so rides on moving ladders count, and with each portal taken away in turn: the manor's right mirror and its last mirror are needed; World 2's last three too, with levers as part of the search: the theatre can't be crossed with its levers left either way, and its stage door, the lighthouse's beacon and the museum's vault and exit doors are all needed). Worlds 3–6 were checked the same way: the bot from the spawn and from every checkpoint at several seeds and start times, the search with each gate shut in turn (extended to swim, ride carts, carry keys and turn the turning rooms), random runners for clipping, draw calls, phones and an online room; the merged page still plays Worlds 1 and 2 exactly as before (same parts, codes and bot runs). The checking bot has route steps for the new things: ladder (a plan saying wait, grab, hang or climb), dial, tp, kick (a sliding crate) and steps (stepping stones, by way of a hub platform).

  • e63334287a3deaa3/odd-one-out/: Odd One Out (Back Room), Props vs Hunters (prop hunt) for up to 16 players (Three.js, Firebase Realtime Database rooms under rooms/ph-…, same project and rules as Rumble Karts; Quick match uses rooms/ph-pub-1 to ph-pub-4; invite links go through the home page as #ph-<code>). Props aim at an object and turn into an exact copy of it; hunters wait in a closed room during the hide time, then hunt with a hitscan rifle, and every shot that hits a real object costs them health (the host's choice: free, 3 or 5 HP). A match is 3 to 10 rounds and the hunters are picked so everyone takes turns (newRound, by how often each player has hunted, hn). Every object you can turn into is a type in buildProps (defProp(id, name, parts), where parts are plain Three.js shapes merged into one geometry per material, with the origin in the middle of the base and the front facing +z); a map draws its copies of a type as instanced meshes (buildInstances) and a disguised player draws the very same geometries and materials (propModel), so the two can't be told apart, and both use the type's own box for collisions and shots. Health scales with the type's size (40 to 200) unless the host picks All 100. To add a type, add a defProp line (and {elig: false} for things players shouldn't turn into); to add a map, write a build… function with the Builder helpers (box for walls and fixed furniture, at for turned furniture, place(id, x, z, yaw, y) for turnable objects, kit for walls with doors, floors, ceilings and lights, hunterRoom for the waiting room outside the map) and add it to MAPS; keep every placed object where a player can walk up to it. Like Chromaflage, everything travels through each player's own node at rooms/ph-<code>/players/<uid>, because the shared rules expect n, c, x, z, h, ts there and refuse other paths for new prefixes: position and look (y, p aim pitch, d the type you've turned into), the last shot (sh, for tracers), running hit totals for the round (hd: round|uid:hits,…|hits on real objects|session, where the session is when that browser joined, so a reload starts the totals again) and, from the host, the round state as JSON in rs with host number he. The host (newest host number, else whoever has been in the room longest, and only once the room's players have loaded) runs the phases (lobby, hide, hunt, end, over) and works out health from the hit totals, checking them loosely (the shooter must be a living hunter, the target a living prop within 90 m, and no more hits than the rifle could fire); if it leaves, the next player carries on from the last round state it saw and counts the totals it can already see as done. Each browser reports its own hits, so it isn't cheat-proof. Anything read from another player's node is untrusted: a round state is rebuilt by sanitizeRS (only the fields the page uses, numbers clamped, player and prop ids checked; match settings kept in range by cleanSet), so a new field in the round state has to be added there too, and anything from a node that goes into the page as HTML must be escaped (esc) or turned into a number first. The round state goes out whole every time it changes (1-2 KB with 16 players) and during a hunt it changes with every hit, so the host coalesces hit-driven changes (health hf, eliminations el, finds k) to at most 4 publishes a second (Net.rsDue; a new phase or round goes straight away); the shape of rs is unchanged, so old and new hosts and guests mix freely. A tab in the background keeps its node fresh (and, if it's the host, the round moving) with a one-second timer; a player whose page stops altogether stops counting after 12 s but stays drawn where it was for up to a minute. Sounds a hiding prop makes go through propSfx, which keeps them from hunters in the waiting room. Pings are worked out on every screen from the hunt's start time (every 10–30 s, every 10 s in the last minute) and play from each prop's position. Points: 100 per prop found, 10 per 15 s survived, 150 for surviving the round and 50 to everyone on the winning team. Late joiners watch the hunters until the next round; players who are out watch their own team (← →, or tap Next). Practice: Hunt practice against prop bots that copy an object standing in a group and park beside its twins, and Hide practice against hunter bots that notice movement, things out of place and pings (and now and then shoot a real object). Settings, name and keys are saved only in the browser (oddone-… and keybinds:odd-one-out). Parties can launch it (its row in GAMES in party.js). On touch screens the page adds touch to body; the body.touch rules near the end of the stylesheet place the HUD for phones upright or sideways (the touch buttons keep their places in their grid by hiding with visibility, see updateTouch). A test hook (window.__ph, with advance(seconds) to run the game without drawing) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/tumble-trials/: Tumble Trials (Back Room), a knockout party show for up to 16 players (Three.js, Firebase Realtime Database rooms under rooms/ko-…, same project and rules as Rumble Karts; Quick match uses rooms/ko-pub-1 to ko-pub-4; invite links go through the home page as #ko-<code>). A show is two to four rounds and a final: races (the first few across the line qualify; falling in the slime puts you back at the last checkpoint), survivals (no respawns; whoever is still up when the clock runs out or when only the qualifying number are left goes through) and a final for the crown. Every show opens with a race and has at least one middle round before the final: two rounds before the final in a Short show or with fewer than 10 players, three in a Normal show of 10 or more or a Long one, four in a Long show of 10 or more; middle rounds alternate survival and race. Qualifying numbers come from planShow: about a third of the starters reach the final (finalSize, at least 2 and at most 6) and the knock-outs are spread evenly by ratio, so in a Normal show 16 players go 16→11→7→5→final, 12 go 12→8→6→4→final, 8 (a solo show with 7 bots) go 8→5→3→final and 4 go 4→3→2→final; two players play two races where both go through, then the final, and three play a race where everyone who finishes goes through, then a survival. nextRound follows who actually came through (a survival always knocks out at least one; one planned with only two left becomes an unplayed race both can go through), and a round before the final always sends at least two on: if fewer make it (a race that runs out of time, or the last few falling together), whoever got furthest in a race, or was knocked out last in a survival, goes through too. A show can only start with one player when nobody else is left at the last moment; it is then a single race. Levels are built in code by the build functions in LEVELS with the B helpers (boxes, cylinders and spheres that are both the mesh and the collider; the meshes are the hitboxes, so what you see is what hits you): Whirligig Way, Teeter Tide, Gatecrash Gauntlet, Springboard Skyway and Swingset Straits (races), Whirlwind Dish and Gap Rush (survivals), Honeycomb Havoc and Last Spin (finals); on Whirlwind Dish and Last Spin the low beam (0.08 to 0.58 m up) has to be jumped, and the high beam's underside (HIGH_BEAM_Y, 1.95 m) is well above a standing bean (1.6 m), so you can stand, run or dive under it but not jump into it; the Warm-up Yard is the lobby and menu backdrop and the Crown Stage the podium. Every moving obstacle's position is a function of the round clock (seconds since GO, from the server time offset), so all players see the same hammers and walls without sending them; the seesaws react to weight, so the host runs them and sends their angles. Each player writes only their own node rooms/ko-<code>/players/<uid>, because the shared rules expect n, c, x, z, h, ts there and refuse other paths for new prefixes: position and motion (y, m), look (lk: face, pattern, pattern colour), rd ready, k the round the position belongs to, fin/out ("show:round:ms" when you cross the line or drop out), tt the honeycomb tiles you touched, gr/fr grabbing and breaking free. The host (newest host number he, else whoever has been in the room longest, chosen only after the room has loaded) publishes the show as JSON in its own rs (phase, plan, roster, who qualified and who is out, in order; it goes out whole every time, so changes within a round are coalesced to at most 4 publishes a second, Net.pubRS/flushRS, while a new phase or round goes straight away; the shape of rs is unchanged), its bots in bt, seesaw angles in sw, and every touched tile in its tt; a tile vanishes a fixed delay after the earliest touch anyone reported. Guests treat the host's rs as untrusted: sanitizeRS rebuilds it field by field (known phases and levels, finite clamped numbers, ids of a safe shape, lists capped at 32, at most 24,000 characters) and drops it if anything doesn't fit, and names and colours from other players are escaped or validated before they reach the page. Guests also run the show on the host's clock (they measure the gap from the ts the host writes), and every browser rewrites its ts once a second even from a background tab, so a host who switches tabs isn't taken for gone. The host's browser runs the bots (off by default, "Fill seats" in the lobby) and decides qualification from the reports, so it isn't cheat-proof; if the host leaves, the next player carries on from the last show state and takes over the bots. The physics is the game's own: a fixed 60 Hz step, a capsule of three spheres pushed out of boxes, cylinders and spheres, platforms that carry you, and knockback from the speed of whatever hit you. Bots follow each race's path of nodes and wait at safe nodes until the obstacles ahead will miss them; the same brain is the test autopilot. Keys are rebindable through keybinds.js (saved as keybinds:tumble-trials). On computers, clicking the game captures the mouse (Pointer Lock) and moving it turns the camera with no button held; a "Click to look" hint shows while it isn't captured, and the click that captures it doesn't also grab. Esc, the pause menu, the main menu, results and podium cards and the Controls panel let go of it (Esc in the online lobby only frees the mouse for the lobby panel; a second Esc opens the menu). A refused capture (Chrome refuses one within about a second of Esc) is only counted: after three in a row the hint goes and drag-to-look carries on, but every click still tries again. A two-finger trackpad scroll (or mouse wheel) also turns and tilts the camera; a pinch is swallowed so the page doesn't zoom. Sensitivity (Settings and the pause menu) scales mouse, scroll and touch look and is saved as tumble-sens, clamped to 0.2–3 (anything unreadable loads as 1). On touch screens the page adds touch to body: a stick, Jump, Dive and Grab buttons and drag-to-look, hidden while you watch or read a results card (noctl, cardup). Name, look, settings, best practice times and stats (shows, crowns, rounds qualified) are saved in the browser under tumble-…; nothing is stored online between shows. Parties can launch it (its row in GAMES in party.js). A test hook (window.__ko, with advance(seconds)) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/deepword/: Deepword (Back Room), a daily "rarest answer" word game with a multiplayer mode, Blowfish Brawl (one canvas for the ocean, no libraries; Firebase Realtime Database, same project and rules as Rumble Karts, prefix dw; invite links go through the home page as #dw-<code>; Quick match uses rooms/dw-pub-1 to dw-pub-4; in the Parties list as { k: 'dw', … max: 12 }, since party.js lists its own games). Prompt bank: prompts.js sets window.DW_BANK: lists (named answer lists, one string each: answers separated by ;, spellings of one answer by |, and a commonness weight 1–5 after :, 5 being what most people say), prompts ([category, question, list name or an inline list, optional filter], 332 of them, each with 20–200 answers; categories geo, animals, food, words, pop, science, sports, life), words (about 2,400 plain English words in five commonness tiers, the list behind '@words' letter prompts), labels (lists the Brawl may turn into " starting with X" rounds) and rhymes. M('a','b') merges lists (the game keeps the first copy of a repeated answer). A filter is a small regular expression on the normalised answer, limited by safeRe to letters, digits, spaces, ^ $ . ( ) | [ ] - and {n}, and is checked against every spelling of an answer. Answers are normalised by norm (lowercase, accents and punctuation stripped, & → "and", a leading "the" dropped); category prompts forgive a stray plural, word prompts don't. To add a prompt, add a list and a p(...) line and keep it between 20 and 200 answers. Daily Dive: picksFor(day) seeds a generator with the local date and takes one prompt from each category with 15+ answers, so everyone gets the same eight. Each diver's answers go in their own node rooms/dw-daily-<YYYYMMDD>/players/<uid> (n, c, x, z, h, ts as the shared rules require, x/z/h 0, plus j and a, the answers so far as the answers' first spellings separated by ;), which stays for the day (no onDisconnect) so later divers can count it. After each answer the game reads the day's nodes once and counts how many divers gave the same answer (Daily.counts); score(w, k, n) gives 80/55/35/20/10 points for weight 1–5, cut by up to 55% as more of today's divers (k of n) said it, plus 10 for an answer nobody else gave once 4 have answered, never under 5; every point is 15 m, capped at the Challenger Deep (10,935 m). Offline it falls back to the weights alone. 30 seconds of air per prompt. Saved in the browser under deepword: keys: days ({YYYYMMDD: depth}, for the streak and the calendar), best, dives, today (the dive in progress, so a reload resumes it), last (the finished dive, for reopening today's results), mute, room; the name is in deepword-name (party.js restores it there). Practice serves random prompts and can be paused with Esc. Blowfish Brawl: each player writes only their own node rooms/dw-<code>/players/<uid> (n, c, x, z, h, ts, j join time, a their answer as <match>.<round>|<text>|<server time>, rm the match id they want a rematch of) and the host also writes the round state as JSON in rs with host number he. The host is the live player with the highest he carrying a valid round state, else whoever joined first (Net.elect); it runs the phases (lobby, count, round, reveal, over), picks prompts from brawlCandidates (plain categories in round 1, then letters, word lengths, letter pairs and rhymes, each with enough answers for the room), fills the room to 4 with bots when the box is ticked, and judges (judge: answers in timestamp order, so the first sender keeps an answer and later ones are duplicates; blank, invalid or duplicate puffs you, three puffs pop you, and if everyone left would pop at once nobody does). Rounds start at 10 s and lose 0.5 s a round down to 6 s. Guests rebuild every round state with cleanRS (known fields only, ids checked, numbers clamped, the prompt's list must exist and its filter must pass safeRe), so a new field must be added there too; names and answers are only ever set as text. A background tab keeps its node fresh, and if it's the host keeps the rounds moving, with a one-second timer. Leaderboards hooks: BackRoomStats.max('depth', …), add('dives', 1) (finished Daily Dives) and add('brawlwins', 1). A test hook (window.__dw, with setToday('YYYYMMDD') to play a made-up day) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/puffer-party/: Puffer Party (Back Room), a hot-potato category word game for 2 to 12 (one canvas for the sea and the pufferfish, plain elements for the ring and the answer box, no libraries; Firebase Realtime Database, same project and rules as Rumble Karts, prefix pf; invite links go through the home page as #pf-<code>; Quick match uses rooms/pf-pub-1 to pf-pub-4; in the Parties list as { k: 'pf', … max: 12 }). Answers come from Deepword's bank: the page loads ../deepword/prompts.js (never edits it) and uses the same norm (lowercase, accents and punctuation stripped, & → "and", a leading "the" dropped), the same list format and the same "keep the first copy" merging as M(). CAT_DEFS lists the categories as [key, name, hint, Deepword list(s) joined by +, easy] (FOODS is fourteen food lists merged); LETTER_DEFS adds "Words / Animals / Foods starting with X", checking the letter against every spelling of an answer. A category is only used if it has at least MIN_ANSWERS (60) answers, so a list that shrinks or disappears simply drops out; 95 make the cut today. Category rounds forgive a stray plural, word rounds don't, and a repeat is caught by the answer's first spelling, so "USA" after "United States" is "already said". Rules: players take turns round the ring; the one on the spot types answers until one fits and hasn't been said in this category (wrong guesses only cost time). The fuse is 12 s, 0.45 s shorter after every answer down to 4.5 s, and each new round starts 0.5 s shorter (not under 8 s); a category changes after 7–15 answers (a bit more than the players left, plus a 1.5 s bonus on that turn) and after every pop. Time out and the puffer pops: lose one of three lives, and the next player starts a new round with a new category; at zero lives you're out. Last one left wins; if only bots are left the healthiest bot wins at once. Online: each player writes only their own node rooms/pf-<code>/players/<uid> (n, c, x, z, h, ts as the shared rules require, x/z/h 0, plus j join time, a their latest attempt <match>.<turn>.<k>|<text>|<server time>, ty what they're typing <match>.<turn>|<text>|<''|b|d> (throttled to one write per 150 ms, shown under the puffer for everyone), rm a rematch request), and the host also writes the round state as JSON in rs with host number he, the same host election as Deepword's Blowfish Brawl (Net.elect: the live player with the highest he carrying a valid state, else whoever joined first). The host runs the phases (lobby, count, turn, pop, over), plans the bots' answers and timing into the state (ba, bt, so a new host can carry on mid-turn), judges attempts (judgeAttempt; an answer stamped up to 0.4 s after the fuse still counts, and the host waits 0.7 s before popping) and turns down a bad one with nk; players who leave are out (dropLeavers). The rules are pure functions (acceptAnswer, popTurn, afterPop, newMatchRS). Guests rebuild every round state with cleanRS (known fields only, ids checked, numbers clamped, the category key must exist), so a new field must be added there too; names and answers are only ever set as text. A background tab keeps its node fresh, and if it's the host keeps the turns moving, with a one-second timer. Bots fill the room to 4 when the box is ticked; Solo plays against 1–7 bots on this device with no database (it pauses while the tab is hidden). Saved in the browser under pufferparty: keys: name (plain text; party.js restores it), mute, room, bots, seenHelp, and wins, answers, streak for the menu. Leaderboards hooks: BackRoomStats.add('answers', 1) and max('streak', …) per accepted answer, add('wins', 1) for an online win with at least two humans. A test hook (window.__pf) exists only when navigator.webdriver is set. Puffer Party draws no other players' positions, so it doesn't use netsmooth.js. School-safe answers: at load time it leaves out a short list of Deepword entries a school wouldn't want a bot to say (BLOCK: the Sex Pistols, N.W.A, spotted dick, tit and booby, butt, bra, Hooters, dating apps and Omegle, alcohol, bars, casinos and the like; WORD_BLOCK, for "Words starting with" rounds only: kill, gun, pistol, knife, weed, blunt, hate), so bots never say them, they don't count toward a category's 60 and typing one just "doesn't fit"; Deepword itself still has them. (That costs "Animals starting with P", which had exactly 60, so 94 categories make the cut.) In-game chat: loads gamechat.js (T to chat while in a party; T still types in the answer box).

  • e63334287a3deaa3/lunge-line/: Lokop (Back Room; first released as Lunge Line, and the folder, the room prefix gd, the lungeline: storage keys, keybinds:lunge-line and the stats id lunge-line all keep that name on purpose, so saved settings, stats, leaderboard rows and invite links carry over), an 8-bit side-on sword tug-of-war for two (one small canvas, Web Audio, no libraries; Firebase Realtime Database, same project and rules as Rumble Karts, prefix gd; invite links go through the home page as #gd-<code>; Quick match uses rooms/gd-pub-1 to gd-pub-6, preferring a room where someone is waiting alone, and always plays the standard rules on a random arena; in the Parties list as { k: 'gd', … max: 8 }). Rules: one touch of a blade kills; a blade is deadly on contact (thrust, lunge, or walking or jumping into the point), unless the other blade is held at the same height in the way. Three heights (low, middle, high); moving your sword across theirs disarms them; switching into a thrust within 8 ticks of it landing is a parry (white flash, the attacker staggers). Up above high raises the sword to throw; a high guard deflects throws, crouching and rolling dodge them. Facing: a fencer always faces the way they last pressed and turns at once (a press the other way while standing turns on the spot; a tap the way you face is a short step; holding runs); standing still never turns you, there is no auto-turn or backpedal, so walking away turns your back; the finisher and stomp only work facing the fencer on the floor (or standing over them). Going down: a tap of down lowers the sword one height; holding down for 8 ticks (crouchHold), or pressing it with the sword already low or with no sword, crouches (p.cr, a deep squat drawn about 40% of standing height, body 22 px high instead of 54: under a middle or high blade, a low one still hits; the sword is tucked in, so it neither blocks nor cuts; shuffle at crawl; let go to stand; p.hd counts how long down is held). Down while running rolls (under middle and high blades, not low ones). The kick (down held + attack on the ground, armed or not, with no need to wait for the crouch; state SWEEP, how kick): a 7-tick wind-up on one hand, a slide of about 60 px along the floor until tick 21 that hits with the whole leg (body to foot, 30 px ahead, so it lands from blade range out to about 100 px), then getting up until tick 36; a sword held low and facing the kick stops it (the kicker is touched); it knocks the other fencer flat and their sword flies off a short way (they can mash any button to get up sooner); it goes under a middle or high guard, but a sword held low touches the kicker first, and a low thrust started on reaction beats it. Pushing into a ledge from a jump climbs it (never out of a pit). Hanging: a fencer falling past an edge (world.edges: any corner of ground or end of a platform with a drop of 50 px or more beside it and room to stand above) whose hands (REACH, 50 px above the feet) pass its level within 8 px of it grabs it and hangs (state HANG: feet HANG_D = 44 px below the edge, facing it, sword put away so it neither blocks nor cuts; p.ex, p.ey, p.es are the edge). Holding down while falling drops past on purpose; the runner dropping into their own winning pit never grabs (anyone else does grab a pit's lip). Up or jump pulls you up (the CLIMB state, 14 ticks, onto the ledge 12 px in); down or a new press away from the wall lets go, and you can't grab again for regrab (30) ticks. You can hang as long as you like, but the hands and head come up to the ledge, so a low blade from up there touches you (a middle or high one goes over), and a kick or a stamp (attack standing over them unarmed, or with the sword up) knocks you off (kd with how off). Of several edges or ledges in reach, the nearest (then the highest) is taken, so the choice never depends on list order. Bots pull themselves up (Easy after about 0.75 s), or let go when the opponent is right below on firm ground. Unarmed: punch, the kick and dive kick (attack in the air) knock down; a high guard skewers dive kicks; attack near someone lying down finishes them. Crouching over a dropped sword picks it up (the nearest, the one in front on a tie; two reaching for one, the closer gets it). Long stages and the camera: each stage is a long stretch you can roam both ways (2,400 to 2,880 px; the camera shows 640 of it). The camera's focus is part of the match state (S.cam, with its speed S.cv and the runner's look-ahead S.la, all in hashState), so every mode and both online players agree on it and the rules use it. With nobody on the run it follows the midpoint of the fencers, and the edges of its 640-px window (VIEW) are soft walls: they stop you going further out but never push, so the two can't get more than a screen apart, but together they can walk anywhere along the stage. With a runner it looks ahead of them (look 150 px, so they sit about a third of the way in from the trailing edge), swings round (lookV 5 px a tick) only when the runner actually moves the other way (turning on the spot does nothing), and keeps a defender who is ahead of the runner in the picture; the defender can't run off its leading edge (nor more than 580 px ahead of the runner). A defender off the trailing edge for more than lost (45) ticks is left behind (lost event): they vanish with a puff (no paint, no touch, no death counted) and come back like after a touch. The camera eases (camK), speeds up gently (camA), slows in proportion to the distance left so it never overshoots, has a top speed (camV, 10 px a tick) and a 1.5 px dead zone; it never shows past a stage's ends; a stage change cuts straight to the new stage. The only solid walls are the two ends of the stage: the winner of the last duel (Tide right, Bloom left) passes through the end on their side into the next stage; nobody else can leave it. The loser respawns after about a third of a second (respawn) 250 to 320 px ahead of the runner (or, with nobody on the run, on their own side of the opponent at a similar distance), found by searching the ground (findSpot/spotOK): inside the stage and the window, on level ground with the same height a little either side, not by a ledge, not under a roof lower than 60 px, not on top of anyone; if the runner is about to leave the stage there is no room, so they wait for the next stage. Gaps kill. The ending: the last stage at each end finishes in a pit (the last PIT = 92 px of the line, world.goal); the fencer who has the run wins by dropping into their own end pit (S.win, a win event, and the simulation then stands still), while anyone else who falls in just dies and comes back like any other fall. The page then plays the drop: a moment of falling, a cut to a long shaft with walls and streaks rushing past and the winner tumbling (about 2.5 s, with a falling-whistle sound), then a big pixel banner " WINS" and a bar with Next game and Menu; any key (Esc goes to the menu) or a tap on the picture starts the next game with the same settings (online: back to the lobby and ready at once, winner stays on; tournament: the bracket). Code: everything that decides the match is in <script id="core"> (window.LLCore): a fixed 60 Hz step step(S, world, input0, input1) on plain objects, with no Math.random, clocks or trig, and positions and speeds kept on a 1/256 grid with rounding that's the same either side of zero, so the same inputs give the same match in every browser and a mirrored match plays out as an exact mirror (fair to both sides; ties such as facing when two fencers stand on the same spot, or which sword is picked up or cleared away, are settled by position, never by player order; checked by swapping players and left/right over 400 bot matches, comparing fencers, facing, loose swords and the camera; walls are met in the order you reach them, loose swords are capped once at the end of each tick, and two fencers coming back on the same tick place themselves independently, all so player order never matters). Timings and distances are in BASE_T, tuned to be calm and readable (run 3.7 px a tick, about 220 px/s or a screen in 3 s, top speed in 6 ticks, stops in 4; lunge 17 ticks; roll 5.5 px a tick for 16 ticks; a slightly floaty jump that hangs at the top while held; a dive kick of 4.6/4.8 px a tick; respawn 36 ticks; inputs buffered for 8 ticks, 6 ticks of coyote time); variants change them in tuning() (lowg; turbo puts back the old quick numbers: 324 px/s in 3 ticks, lunge 14, roll 7.4, respawn 22); thr turns throwing off, bounce lets thrown swords bounce twice, N is 3 (the default, since stages became long: a novice's match against Easy then has a median of about two minutes), 5 or 7 stages; prac is practice (below). The match settings travel online as 7 numbers whose 4th (it used to be hazards) is always 0. Arenas (ARENAS): each has a middle stage and three stage layouts (st, built into LAYOUTS by layout()) for the runner heading right, mirrored for the left half, so both sides are fair by construction; layout C is always the last stage and ends in the pit (7 stages use the middle, A, B and C each side; 5 use the middle, A and C; 3 the middle and C). Stages are long: the middle 2,880 px (4.5 screens), A and B 2,560, the pit stage 2,400, so the whole line is 7,680 px (12 screens) with 3 stages, 12,800 (20) with 5 and 17,920 (28) with 7, where it used to be one 640-px screen per stage (1,920, 3,200 and 4,480). A layout is a short list of features (G gap, T low tunnel, P lone platform, W upper walkway with open ground under it, U/D a raised section with three 20-px steps up to a plateau 54 to 60 px high and a cliff or walkway at the other end, S a bump of steps) spread evenly along flat ground, so there is at least 200 px of flat ground at both ends and between features; the middle stage is built from half a list mirrored about a centre feature, so it is the same both ways. They are plain fencing ground with no moving or changing parts: the Lantern Market (night rooftops, awnings and a covered lane), the Old Aqueduct (sunset over the sea, stone arches and low tunnels), the Dune Observatory (dusk dunes, a raised dais in the middle, steps, domes) and the Signal Yard (a rail yard at night, freight-car walkways and gantries). Every layout has flat ground at y 300 at both ends so stages join up. Bot: botInput sets the same buttons a player presses (it turns round by pressing a direction, like a player); it sees the opponent's sword height react ticks late, and the four levels differ only in the numbers in BOT: Easy (the default for new players) is a clumsy beginner: 34-tick reactions, long pauses (idle), a visible stop before each strike (tele), often the wrong height (match, wrongH), sometimes walks onto a held blade (blunder), slow to chase a runner (chase), slow to turn (face), and never parries, disarms, throws or dodges a throw; Normal is about the old Easy, with a short tele and the odd pause; Hard and Expert are the old Normal and Hard. The level is saved as lungeline:bot; an older lungeline:diff maps easy to Normal, normal to Normal and hard to Expert, and a saved tournament list without lungeline:tourv is mapped the same way. Tuned against a scripted novice human (walks at the bot, sees it 15 ticks late, picks its height at random half the time, attacks every 40 to 90 ticks in reach, never rolls, kicks or dive-kicks; the bot's pathing, without those moves, gets it round the arena): over 200 matches on 3 long stages the novice beat Easy 198 times (99 %, median match 145 s; with one-screen stages it was 179 times and 40 s), Normal 43 (21.5 %, median 177 s), Hard 1 and Expert 1; on 5 stages the median against Easy is 266 s, which is why 3 is the default. It mirrors heights, strikes when they differ, parries (Hard), disarms, throws, picks swords up, reads the guard for sweeps and dive kicks when unarmed, plays out its jump arc (with the real hang-time gravity, gravAt) before jumping a gap, looks further ahead before stepping off a platform, and as the runner treats its own end pit as the way out rather than a hazard. Headless, Expert beats Easy 60–0 (median 64 s) and Hard beats Normal 60–0 (median 84 s). Equal bots are a long tug-of-war on long stages (160 matches per level over 3, 5 and 7 stages and every arena, capped at 20 minutes: median 509 s for Easy, 267 for Normal, 193 for Hard and 240 for Expert, with 42, 13, 5 and 11 matches still going at the cap, but never 40 s without a touch, a knock-down, a stage change or someone left behind); a median duel lasts 1.4–2.0 s. Bots break a long standoff with matched blades by switching height every couple of seconds once nothing has happened for 6 s, keep running as the runner when the other is right behind them, attack a turned back, and settle a tie over a dropped sword by punching or shuffling closer. Before a change to the bots, play old against new headlessly over a couple of hundred matches; single results swing a lot. Drawing: the canvas is 180 game pixels high and up to 320 wide (the camera's whole 640-px window; narrower on upright phones, which show the part of the window nearest the focus that keeps your own fencer at least 100 px from the edges, gliding rather than jumping when that has to move a long way, with a small arrow in the other fencer's colour at the edge when they're out of the picture); the camera is rounded to a whole game pixel once and everything is drawn from that number, rounded relative to the fencer it's keeping pace with while it does, so nothing shimmers; on a 60 Hz screen each frame counts as exactly one tick, so frame-time jitter never shows as a stutter while scrolling, and one game pixel is 2 world px; layout() scales it up by a whole number when that loses less than a fifth of the room (4× at 1280×800, 5× at 1920×1080, 2× on phones), else by the exact fit, always with image-rendering: pixelated. Everything is drawn in game pixels: a dithered sky, two repeating parallax strips per arena (buildLayer), each stage drawn once per match into its own long canvas (buildRoom, its set pieces laid out a screen at a time and mirrored on the left half); the sky stays put and the two strips scroll at a tenth and a third of the ground's speed. Arena colours are mixed toward near-black (DIM) so the fencers and paint are the brightest things on screen. Stages: each stage has its own look: the sky and strips take a tint by stage template (STAGE_TINT: the middle as it is, the first pair cooler, the second warmer, the pit stages deep red; the new one fades in over 0.4 s when the view moves on) and stageProps draws its own set pieces behind the floor (a landmark in the middle stage, stalls, columns, telescopes or freight cars, chimneys, a broken arch, tents or a water tower further out, and chains and warning posts in the colour of the fencer who wins there on the pit stages); crossing into the next stage shows a banner ("STAGE 4 OF 7", counted from the runner's own end, or "LAST STAGE") for under a second with a small sound, without stopping play; the bar at the top shows the stages as blocks (the lit one with a dark tick that slides along it as the camera moves through the stage), the pit stages at the ends in the colour of who wins there (Tide right, Bloom left) with a pit notch and an arrow outside, cleared stages in the runner's colour and the current one lit white; the fencers as plain flat pixel figures, one solid colour each (Tide teal, Bloom rose) with a blocky head, a thick body, thin limbs and a 1-pixel pale sword, no face, outline or shading, about 27 game pixels tall, posed frame by frame from the simulation state (pose/sprite: idle at three heights, step, run, crouch, shuffle, jump, thrust, lunge, parry flick, roll, dive kick, punch, the kick's wind-up, slide and getting up, raised sword and throw, disarmed, stagger, knocked down, getting up, finisher, stomp, ledge climb, hanging from a ledge (arms up, sword on the back) and pulling up, unarmed, and a buckling pose while a touched fencer flashes), and text in a 5×7 pixel font defined in the page (FONT, text()). The sword is always drawn at its height (low, middle, high), even in a roll. Paint: a touched fencer flashes white for the hit-stop (hitstop, 8 ticks, in the simulation) with a small screen shake, then bursts into paint of their colour (drawing only, never read by the simulation, so rollback, checksums and spectators are unaffected; checked by replaying a real-time match's recorded inputs headless and comparing hashState). Drops fly with gravity and stick where they land on floors (2 to 3 pixels thick), walls and ceilings (with short drips) and platform tops (with drips hanging under the ends); a pool 20 to 35 pixels wide and up to 6 thick spreads where the fencer fell, stops at the floor's ends and runs down the front of the floor there and under itself. It is all drawn straight into one transparent canvas per stage (R.decal) that lasts the whole match, on every stage, through respawns, banners and stage changes, and nothing is ever trimmed (a canvas holds any number of stamps; only drops still in the air are capped, at 900). Before this, the splats were 1-pixel stamps on the floor's bright edge line in mixed shades with a 15-pixel pool, so they read as speckle and seemed to vanish, and each stage stopped taking new stamps after 1,400 (about ten touches). The sword drops from the simulation as before. Online (rollback): every node carries v, the simulation version (NETV, 3 since the long stages, camera and ledge hanging; 2 was the calmer movement, facing and kick; pages from before have none and count as 1). Only players on the same version are seated, watched or played; anyone else in the room is named in the lobby as having an older copy that needs a refresh, while they're there you can't press Ready, and Quick match avoids rooms with them. (An old page can't be told anything, but it never gets a match from a new one.) The first two people in the room by join time j fence (seat 0 Tide is the host); others watch; after a match the loser re-queues behind anyone waiting (winner stays on). Each player writes only their own node rooms/gd-<code>/players/<uid>: n, c, x, z, h, ts (x, z, h are their position and sword height, for the shared rules only), plus j join time, rd ready, m (host only: id|seed|cfg|D|T0|uid0|uid1, the match), ma accepted match, i their input log id.<first tick>.<run-length codes> covering the last 240 ticks, tk tick, ad how far ahead they run, hc a state checksum every 60 settled ticks, ss (host only) a snapshot of the settled state every 120 ticks for watchers and repairs, fx the finished match and winner, and pq/pe a ping counter and its echo. Both run the same simulation: your own inputs go in D ticks ahead (2–5, picked by the host from the measured ping), the opponent's are guessed (their last input) and, when the real ones arrive and differ, the game rewinds to that tick from a ring of saved states and plays forward again (at most MAXR 30 ticks ahead of what it knows, else it waits); the one running ahead slows by a tick now and then. Effects play once per event key after a rewind, and a touch (its sound, paint burst, "runs" banner and the stats) and the win (the drop into the pit) are only shown once both players' inputs for that tick are in, with the victim drawn where they stood meanwhile, which the 8-tick hit-freeze mostly covers. Kills, wins and stats come only from settled ticks, so both players always agree; if the checksums ever differ the guest reloads the host's snapshot and replays. Watchers play both input logs a few ticks behind from the host's latest snapshot. The other player is drawn from the simulation, not from position updates, so netsmooth.js isn't used; small corrections after a rewind glide out over a few frames. A background tab keeps stepping on a 250 ms timer. Modes: Vs bot (Easy/Normal/Hard/Expert), Practice (alone in the chosen arena straight away, no score or win and no stats or room: prac in the match settings lets you walk through every stage either way with the camera following you (looking ahead the way you're going) and a fall puts you back in the middle stage; a move list beside the picture on a computer, above it on an upright phone and across the top between the button gutters on a phone on its side, shows each move with your own bound keys or the touch buttons and ticks it when you do it: move, jump, sword up and down, stab, lunge, crouch, kick, roll, dive kick, throw, hang and climb; a Dummy switch (lungeline:dummy) adds player 1 as a target that stands still changing height every few seconds, or fights back as the Easy bot, and comes back 1.5 s after a touch in front of you (or behind, if there's no room), and is brought back the same way if it ends up off the screen; Leave goes back to the menu), Local 2 players on one keyboard (and gamepads 1 and 2), a knockout Tournament for 3–8 people or bots on one computer (byes for odd numbers; bot-vs-bot matches can be decided instantly with the same simulation, up to 6 minutes of play, then by who has the run or which side of the line the fight is on), Quick match and rooms. Keys are rebindable in two sets in the Controls panel (keybinds.js, saved as keybinds:lunge-line): Player 1 WASD, F jump (or Space), G attack; Player 2 arrows, K jump (or Numpad 1), L attack (or Numpad 2); playing alone, both sets work at once (move with WASD or the arrows, jump with F, Space or K, attack with G or L: a key for each on either hand); P or Esc pauses, M mutes. Touch screens get left/right on one side and sword up, sword down (hold it to crouch), jump and attack on the other (under the picture upright, beside it sideways), hidden once the match is over; local 2P and the tournament's two-person matches need a keyboard. Music and sound are chip voices made in the page (pulse waves, triangle and a shift-register noise): a pad arpeggio and bass always, a duel layer that rises as the two close in, a run layer (drums and an arpeggio lead) while someone has a clear run, and a bright line in the last two stages. Menus use the Silkscreen and Pixelify Sans fonts from Google Fonts (falling back to monospace) and square, hard-shadowed panels; the logo is drawn with the game's own pixel font. Leaderboards hooks: kills (touches), streak (most in a row), wins (online). A test hook (window.__ll) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/slipstream/: Slipstream (Back Room), a 3D open-wheel racer with a 22-car grid (Three.js, Firebase Realtime Database rooms under rooms/ss-…, same project and rules as Rumble Karts; invite links go through the home page as #ss-<code>). Bots fill every free seat; the host's browser drives them. Bot skill (DIFF: Easy, Normal, Hard; online races use Hard) scales corner speed, and the bots' braking, boost and lines are tuned by the AI constants: in clean air they brake later and keep the throttle in through corners, and in traffic, off line or near the outside edge they drive more cautiously, which keeps crashes and cuts down. Bots get no extra engine power; they have the same car as the player. Track limits: going off costs nothing but time (a small "Return to the track" hint shows while off), and cutting a corner to gain time adds 2.5 s to the race time; each car's total penalty seconds travel online in pn. Penalties are for the whole race only: lap times, Best, the fastest-lap award and the leaderboard's fastest lap count every lap, cut or not, while the finishing order, the results table and the course record use the race time with the penalties added (raceTime). A cut is judged about a second after the car is back on the track; a car still off the track, or just back on, at the flag is judged there and then (judgeEx), so a cut just before the line still costs 2.5 s. Each driver writes its own players/<uid> node about 15 times a second while racing (the host's node also carries every bot in bt; other cars are carried forward from their speed between updates) and every half second when nothing has changed. Nothing else is saved: settings (name, last room, seat, track, laps, difficulty, camera, mute) stay in the browser under ss: keys. In automated browsers only (navigator.webdriver), window.__ss exposes the race code so a test can run bots-only races and time laps.

  • e63334287a3deaa3/updraft/: Updraft (Back Room), a third-person climbing game: one continuous course from a city street to the Crown 3000 m up through ten zones (Scrapyard Streets round a three-block tower, Lost and Found's giant floating everyday things, Cloud Isles, Thin Air and the Edge of Space up to the old summit at 1000 m, then Aurora Drift, Storm Front, Skyport, Orbit Yard and The Crown above it), with no checkpoints, so a missed jump means falling until something catches you (Three.js, Firebase Realtime Database rooms under rooms/up-…, same project and rules as Rumble Karts; Quick match uses rooms/up-pub-1 to up-pub-4; invite links go through the home page as #up-<code>). The page has two scripts. The first, UpCore (between /*CORE-START*/ and /*CORE-END*/), has no DOM or Three.js in it: the movement constants C (a fixed 1/120 s step; keep them as they are, the whole course is measured against them), the collision (upright boxes, optionally turned or sloped, and cylinders, in an 8 m grid; the vertical move is swept, so even at full fall speed you can't pass through a thin plank), the course (build, from a fixed seed, so every player gets the same world), the checker (check: every jump between neighbouring path platforms against the jump envelope, plus blocked jumps and low ceilings) and the climb bot (runBot, botInput), which plays the course with a player's inputs. The course is the zone scripts in build (the zones themselves, with their heights, are ZONES; SUMMIT is the old summit at 1000 m and TOP the Crown at 3000 m): each places the next path platform relative to the last (go: gap past its edge, height step, turn) while the route circles the middle; set pieces (the scaffold, the crane, the tower's terraces and ledges, the keyboard, swings, lifts, fans, switchbacks with launch-pad shortcuts) are functions there too. K.maxFrac caps every jump between path platforms as a share of a perfect sprint jump: 62% in Scrapyard Streets, which teaches the moves, and 80% from Lost and Found on. The 2000 m above the old summit (added later) is built after everything else in build, from the same random numbers carrying on, so none of it moves anything below: every new piece is marked nz, and a hash of everything else (colliders, path, scenery, signs, fans, shortcuts, secrets) is the same as before it was added; keep it that way (add new things at the end, never in the middle, or every platform after them moves). The new zones each have something the lower course doesn't: Aurora Drift (1000–1400 m, night, curtains of light) has pulse platforms (kind: 'blink', blink: {T, on, ph}) that are lit for 70% of a beat on the shared clock, the next one coming on half a beat later, so two in a row are lit together for a fifth of every beat (check makes sure of that window; a dark one leaves a faint outline and the one you stand on flickers and ticks before it goes); Storm Front (1400–1800 m, rain and lightning) has gusts (course.winds: layers of air, each blowing one way, acc 9 m/s², for about 2 s of every 7–8 s after a 1 s warning of streaks and a whoosh; windAt) that only push you in the air, and every jump there still makes it with a full gust in your face if you steer against it (check), plus wind-turbine blades that turn about their middle (a spinning box, spinArm: ride one end to the other and step off when it lines up again); Skyport (1800–2200 m, a floating town at sunset) has conveyor belts (belt: [vx, vz], 2.6 m/s: walkways with you and treadmills against you, each starting right by the one before), lanes of barges crossing the route, cannons (kind: 'cannon', shot: step onto the pad and it throws you from its middle, the same arc every time, onto the next platform; check plays the throw out with no input) and a blimp whose gondola ferries you 20 m across and up; Orbit Yard (2200–2600 m, over the curve of the world) has low gravity (ZONES[i].g, 0.55; gAt(y), used by the physics, check and the bot; a jump from below 2200 m into it is sized for normal gravity), satellites whose panel arms turn, and long moon leaps; The Crown (2600–3000 m) has waves of steps that rise and fall one after another, a great pendulum, a carousel of moons on a slow ring round the middle (type: 'ring', which the bot may jump off while it moves), and a last spiral of jewels up to the Crown (summit: 2). Each new zone has an intro line shown under its name when you get there, its own sky (ATMO), music (CHORDS, ROOTS, STYLE) and far scenery. After changing it, run check and runBot (in an automated browser window.__up has them, and __up.bot(true, 0) with __up.advance(seconds) climbs the whole course in the page; the bot takes about 1233 s to the old summit and 4400 s to the Crown). Moving platforms are functions of a clock everyone shares (server time), so they're in the same place for every player. Online, each climber writes only their own node rooms/up-<code>/players/<uid>: n, c, x, z, h, ts as the rules require, plus y (height), a (pose), m (highest this run), t (run clock), f (time at the top, the Crown, 0 until then; others ignore an f from anyone whose m is under 2995, which is what a copy of the page from before the course grew sends at 1000 m), b (best height) and j (join time); there's no host. A tab in the background keeps its node's ts fresh from a 1 s timer, so the others don't drop the climber. Parties can launch it (its row in GAMES in party.js). The run is saved in the browser under updraft:run (ver: 2; where you stand, clock, falls, jumps, highest, fin the time at the Crown and mile the time at the old summit; a run saved before the course grew has no ver, and its fin, its time at what was then the top, becomes mile, so it carries on climbing with its clock; standing on a moving platform, also which path platform it is and where on it, on/lx/lz, so a reload puts you back on it; in the air, your speed v and where the fall started ff, so the fall carries on), and a saved spot that's inside something or over nothing (after a course change) puts you on the nearest clear spot on the same level within 3 m, or failing that on the nearest path platform below it; records under updraft:best (h best height; time and wins the fastest climb to the old summit and how many, still the summit and summits stats so times from before the course grew stay comparable; t2 and w2 the same for the Crown, the summit2 stat, lower is better, in stats.js's LOWER, with its own Leaderboards row). Reaching the old summit shows a banner ("Old summit · 1,000 m", your time and best) instead of the finish screen; a marker post stands there and its sign reads "Old summit"; the height bar runs to 3000 m with a gold mark at 1000 m. The finish screen and fireworks are at the Crown. Moving pieces more than the fog's distance above or below the camera aren't drawn, and other climbers are drawn at any height, settings and name under other updraft: keys, and keys under keybinds:updraft. Slow motion (hold F by default, rebindable as slowmo; Slow on touch screens) works only in the air: the climber's own simulation runs at SLOW (0.35×) speed, easing in and out over 0.15 s and stopping at once on landing or a ledge grab. It takes the same fixed C.DT steps, just fewer per second, so a fall lands in exactly the same place; moving platforms, the shared clock and the run clock keep real time (so it costs time in a race), and the climber is drawn between steps so it stays smooth. Nothing about it is saved or sent: others just see you fall more slowly. V (the view action) switches between the camera behind you and first person, as does zooming all the way in (and out again); the choice is saved as updraft:view. In first person the camera sits at eye height (1.58 m, between the last two physics steps so it's smooth at any frame rate), your own figure is hidden but its drop shadow stays so you can judge landings, you always face where you look (so others see you turn), the pitch goes down to 1.52 rad so you can look at your feet, landings dip the view slightly, the eyes are kept clear of anything right beside or above you, and computers show a small dot in the middle; it uses the same mouse capture (Pointer Lock) as third person. Watching someone is always from behind them. Online, after a change, one more update is written once you stop changing, so others don't see you carried on past where you stopped (a quick turn in first person otherwise showed up to 50° over-turned until the 2-second heartbeat). On touch screens: a stick, Jump (hold for a full jump), Sprint (a toggle), Slow (hold), an eye button for first person (hidden while watching), drag to look and pinch to zoom; the body.touch rules at the end of the stylesheet place the HUD for phones held upright or sideways. Secrets (nothing in the game points to them or hints at them; this is the only place they're written down). There are three, and each is a ride: a short scripted path (ride, ridePlan) with the physics switched off, so there's nothing to fall off and no input, slow motion never starts (and lets go at once if it was on), it carries on while paused like the rest of the world, a save mid-ride puts you where it comes out, and positions are published as usual so the others see it. Every ride comes out on the normal route, never above it and never past it. (1) The staff lift: stand at the foot of the crane's mast down on the street (within 3.3 m of its middle) and hold the lift key for a second (G by default; it's in the Controls list as just "Hold to use" and has its own keybind context), or hold Jump on a touch screen; a small "Hold G" ("Hold Jump") prompt shows only on that spot and fills up as you hold. You walk round to a plain yellow door in the mast's +x face and a cab rides up inside the mast (about 6 s, with a hum and a bell) to the top grate, where the route carries on along the jib, skipping the 22 m spiral. Others see a cab round anyone riding in the mast. (2) The service elevator: once you're up the crane, cross the jib and drop onto the terrace round the top tower block (112 m), as the route does, then walk round that terrace to the side of the block facing away from the crane (in the shipped course its +x face; the door is 6.8 m from the middle of that face, toward +z). A plain grey steel double door in a plain panel sits at terrace level there; stand in front of it and hold the lift key (Jump on touch) for a second, with the same prompt. You walk in, a cab rides up inside the block (7 s, hum and bell) and you step out of a small lift room on the roof (168 m) onto the roof itself, which is on the route, so it skips the ledges round the top block. Where it goes is worked out from where the mast is (secrets.elev: the face n, how far along it s); the lift room is the only collider, and check makes sure its exit is on the roof. The shaft and the cab are a separate scene (inScene) drawn over the world only while the camera is inside the shaft (the block's own walls face outward); in third person the camera follows you through the door and sits up in a corner of the cab. Others see you walk in and come out on the roof, and the doors open for them. (3) The hidden updraft: the first stack of books past the roof (the second book of Lost and Found, top at about 169.7 m; secrets.gust.stack) has a column of rising air off its far side, away from the tower and from the jumps either side of it: 4.4 m out from the book's edge, 2.4 m across in radius, from just under the book's top down 20 m. Take a running or walking jump (or step) off that book's top straight outward, within about 15°, and as you fall into the column it catches you with a burst of wind and streaks, carries you tumbling round the middle of the course and up about 106 m in about 10 s, and sets you down on the book just after the cage lift (secrets.gust.land, path 254, 275.8 m). It only counts if the last thing you stood on was that top book and you left it from up there, so a missed jump on the route never triggers it (a fuzz of 22,500 jumps between the neighbouring route platforms, aimed sloppily, found none), and neither do the bot or a watcher. Its path (way, smoothed by curve) spirals up 13 m from the tower's axis, outside the roof and inside the route's ring, and check reports anything solid in its way. Its pose code is a: 10 (tumbling; older pages show it as standing). Found secrets are kept per browser under updraft:secrets ({lift: 1, elev: 1, gust: 1}; an old roof: 1, from back stairs that used to be in the top block, is just ignored), and the first find of each adds 1 to a secrets stat through BackRoomStats.add and shows "Secret found: …" in the feed (recorded by stats.js; the Leaderboards don't show it).

  • e63334287a3deaa3/asteroid-run/: Asteroid Run (Back Room), a fast single-player runner through tunnels of tiles floating in space (Three.js, no Firebase except stats.js). It is deliberately simple and flat to look at: every tile is one flat colour with a thin lighter outline (one tunnel colour per zone, set in THEMES; special tiles have their own colours in TCOL), holes show the plain dark starfield behind, the runners are flat cartoon sprites seen from behind (drawn on a canvas, three frames: two running steps and one in the air), crystals are flat spinning diamonds, and there are no lights or shading: everything uses MeshBasicMaterial (the tiles add a few lines in onBeforeCompile to turn the outline mask into a lighter edge, dimmer for a tile left in the dark), so a wall looks exactly the same once it turns into the floor. You run forward on your own and only sidestep and jump; running off the side of the floor takes you up the wall and gravity turns with you, so the wall becomes the floor (the main trick: a wall is often the way round a hole). A soft dark spot under the runner shows where it will land (none over a hole). Everything that isn't drawing is the AR module between // ==CORE== and // ==/CORE==, with no DOM or Three.js in it, so a test can run it on its own: the runners (CHARS), tile types (T), the levels (LEVELS), the zones and side tunnels (ZONES, SIDES, which also build the map MAP and PRED), the endless tunnel (buildEndless, extendEndless, ENDZ), the fixed 1/120 s physics step (step), the camera (camStep) and the checking bot (plan, runBot). Jumping: by default a tap is a small hop (about 1 m high, 4 rows long) and holding jump makes it bigger, smoothly, up to the runner's full jump once held for about a quarter of a second; letting go early only makes gravity firmer, so nothing drops suddenly. jumpArc works out the take-off speed and the two gravities from the running speed so the full jump is always exactly jl long and jh high (10.5 m, 7 tiles, and 2.5 m for most runners; Moppet 12.5 m and 3.3 m) and a tap is TAP (0.4) of the height. The Settings option Jump: Always full (asteroid-run:set fixed) makes every jump the full one, whatever the hold. A jump still works for COYOTE s after running off an edge, a press up to JBUF s before landing jumps on landing, landing just short of a tile still steps you up onto it, and in a narrow tunnel the far wall is a ceiling you bump into (headroom). Speed rises by a tenth from the start to the end of each level (speedAt); Endless goes from 1x to 1.5x over its first 2 km (endlessSpeed). Runners (each with one fixed full jump; free: how it unlocks for nothing, cost: crystals to unlock it straight away): Pip (all-rounder, free), Brick (heavy: cracked tiles never give way; 6 levels), Vex (fastest, slippery; clear the Frostbelt side tunnel), Moppet (biggest jump; 18 levels), Twitch (quickest sidestep and a double jump; clear Comet Tail), Wisp (hold jump on the way down to float for up to GLIDE_T s; clear Deep Hush), Clink (crystal magnet; 30 levels) and Spool (the Ability key or button lays a glowing bridge over the holes ahead for BRIDGE_T s, recharging in BRIDGE_CD s; 42 levels). Tiles (T): normal, cracked (they hold while you keep running but give way 0.3 s after you step on, almost at once if you land on one, and visibly fall away), ice, conveyors left/right/forward/back, ramps (a launch 1.6 times as long and 1.3 times as high, always the full jump; drawn as a lime wedge), sticky goo, speed strips (1.3 times the speed for 1.5 s, and the view widens), glow tiles (always lit) and Spool's bridges (W.br, never in the rows). Dark rows (W.dk, from D1/D0 in a level) are drawn almost black except from just behind the runner to LIT (14) rows ahead, so the next tiles are always visible. Levels: 57 (indexes never change, since saved progress is keyed by them; the first 32 are the original levels, lengthened, and 32 on were added later), each 20 to 40 s with any runner, built from a line of pieces (the comment above LEVELS lists them) and a fixed seed, so every player gets the same level. Shapes (SHAPES): square, six-sided, eight-sided, narrow square, wide eight-sided, a thin six-sided pipe, three-sided and five-sided. They're grouped into ten zones on the main path, each teaching one idea (holes, walls, crumbling tiles, a mixed Junction, narrow tunnels, conveyors, speed strips, ramps, the dark, and the Core finale), with a fork in most zones (either level opens what's next) and three harder side tunnels (Frostbelt: ice, Comet Tail, Deep Hush), each of which frees a runner. The map is a big scrolling picture (drawn top to bottom on a tall phone); levels are named by zone and place (2-3) or side tunnel letter (F2). The first time a zone is played, Dispatch (the radio voice of the little story: the quake put the asteroid's lights out and you carry the last spark home) says a line or two (say), and the map can show it again. Falling 2.2 m below the wall (DEATH_H) is a fall into space: the camera holds still while the runner tumbles away for a moment, then you're straight back at the start (tries are counted, no limit). Endless is seeded per run (the Daily run uses a seed from the date, so everyone gets the same tunnel all day) and changes zone every ENDZ_LEN (360) m, with the zone's colour and hazards (ENDZ: holes, crumbling, conveyors, speed strips and ramps, ice, the dark, everything); crystals come in short lines, and picking them up quickly builds a streak (every fifth in a streak gives one more crystal). After a fall the score shows at once; Space, Enter, R or a tap outside the card runs again, and once a run you can spend 15 crystals to carry on from the next solid ring (respawn). Crystals: each level's three once each, plus everything from Endless; spent on runners and on cosmetic trails (TRAILS). After changing a level or the physics, run the bot (AR.runBot(AR.buildLevel(i), AR.CHARS[k]) in Node, or __ar.autopilot(true) with __ar.advance(seconds) in an automated browser, where window.__ar exists): every level should be cleared by every runner, and pieces like a face piece need room before them (see the comment above LEVELS). Drawing: one InstancedMesh for all tiles (colour per instance), one each for conveyor arrows, chevrons, cracks, goo, ramps, bridges and falling tiles (only drawn when there are any), one for crystals, the runner, its shadow, the sparks, the stars and the finish frame, so a frame is about 6 to 14 draw calls; tiles are rebuilt only when you move onto a new row. On computers a demo runner (the bot) runs the endless tunnel behind the menu; on touch screens the menu shows a still tunnel. The HUD is three small pills (pause, progress or distance, crystals), a bridge meter for Spool, and short pop-ups for streaks and close calls (jumping off the very end of a tile, landing on its edge). Saved in the browser only: asteroid-run:save ({v: 2, lv: {<level index>: {c: crystal bits, t: best time, n: clears}}, bank: crystals kept from Endless, ch: runner, best: Endless metres, runs, own: runners unlocked with crystals, spent: crystals spent, trail, trails, bestBy: {runner: Endless metres}, daily: {day, best}, seen: {zone: 1}}; a v1 save keeps its levels and crystals, every runner the old rules had unlocked, and its Endless best for the runner it had picked), asteroid-run:set (sound, graphics, touch layout, fixed) and keys under keybinds:asteroid-run. Stats hooks: max('levels', levels cleared), max('crystals', crystals collected in total), max('distance', metres) and max('dist-<runner>', metres) at the end of an Endless run, add('runs', 1) for each level cleared and each Endless run. Touch: arrow buttons bottom left and a jump button bottom right (hold it for a bigger jump), Spool's bridge button above it, or (Settings, Touch: Screen halves) hold either half of the screen to sidestep.

  • e63334287a3deaa3/rampline/: Rampline (Back Room), a low-poly stunt time-trial racer with a track builder, online boards and community tracks (Three.js; Firebase Realtime Database only for the boards and community tracks, under the prefix rl, plus stats.js). One small car, one track, the clock: 30 built-in tracks in four tiers (TIERS: Green, Blue, Red, Black), your own tracks from the builder, and tracks other players publish. Everything that isn't drawing is the RL module between // ==CORE== and // ==/CORE==, with no DOM or Three.js in it, so a Node script can run it on its own: the pieces (PIECES), building a track (buildTrack), the car physics (newCar, step), share codes, ghost recordings, the checks (validate), the track-writing cursor (Course), the tracks (TRACKS, trackOf) and the checking bot (runBot). Pieces sit on a grid of 10 m tiles and 2 m height steps with a quarter-turn ({t, x, y, z, r, w}: type, tile, height, turn, side walls); each has the tiles it covers (fp), how many height steps above it it keeps clear (hh) and its ports (where road leaves it). Pieces: straight, curves (tight, normal, wide, huge), S-bends left and right, slope, steep slope and long climb (+1, +2, +3 steps), climbing curves left and right (+1 step while turning), bump, two banked curves, boost pad, kicker and big ramp (no exit port: they launch you), loops leaning left and right, wall ride, narrow road, gap, platform, start, checkpoint, checkpoint ring (an arch instead of posts), finish, and scenery (tree, rock, arrow sign). The order of PIECES is part of the share-code format: only ever add to the end (the pieces from curveXL on were added with the handling pass). Raised road gets pillars down to the ground automatically (pillars in buildTrack), every 10 m or so, only where no other piece is below that tile and always below the road surface. One set of triangles: buildTrack turns every piece into triangles that are both the collision geometry (type 0 road, 1 solid; FX 1 marks a boost pad) and, merged with one colour each, the single drawn mesh; positions are rounded to 1/1024 m. Physics (PH, PHYS_V 2, fixed 1/120 s step, only + − × ÷ and √ while stepping, so a recording of inputs replays to exactly the same run on any device): the car is a point riding 0.55 m above the road with two 0.75 m spheres for hitting things. Acceleration 20 m/s² tapering to nothing at 82 m/s with drag: 0–100 km/h in 1.5 s, 0–200 in 3.8 s, top speed about 252 km/h. Steering lock and rate fall off with speed (full lock turns in 6 m at 30 km/h and about 55 m at 150 km/h), and the turn rate is capped a little above what the tyres hold, so holding full lock at speed slides a little. Drifting: let go of go and tap brake while steering at more than 50 km/h: the nose holds an angle set by the steering (driftAng), grip is a little higher than normal (driftK), so you get round a tight corner at speed (about 75 % of the grip turn's radius, for 3–8 % of your speed); it lasts while you keep steering and the car is sideways. Downforce grows with speed (stick plus stickHi above 30 m/s), which keeps you on loops, wall rides and most crests; the fastest cars still fly over sharp crests. A share of the road's turn at take-off carries into the air as spin (carryW); in the air the air brake (input 32, Space or Left Shift, or the brake) damps the spin and levels the car for the landing; landing square keeps your speed, crooked costs up to 70 % (with the air brake the bot's landings cost 2–5 %, without it up to 70 % and falls). Boost pads give 1.4 s of extra push up to 1.3 × the top speed. Walls take the speed into them, turn the nose along them and never push a car on the road upwards (barriers on banks and loops lean). Falling off (or 6 s in the air) puts you back at the last checkpoint 0.25 s later with the speed you had there; Backspace does it at once. A run that went back to a checkpoint doesn't count (no best, ghost, medal, stats or board), and the HUD shows "Practice". Starting a track and R are both instant and plain (restart under a millisecond): no countdown, no camera move and no fade; the car waits on the line with the camera already in place, and the clock starts with your first drive key. The menu sits over a still view of a track. Splits against your best show at every checkpoint and at the finish. Gates: a checkpoint counts when you cross it either way; the finish only counts going forwards, once every checkpoint has been passed. Tracks are written with the Course cursor (s, L/R curves with S, M, L, X sizes, sL/sR, bL/bR, up/down, climb/drop, cuL/cuR/cdL/cdR, bump, boost, ring, loop, wall, narrow, gap, ramp + jump(n, dy, speed cap), plat, cp, fin), each with its look (th). After changing a track or the physics, run the bot: it drives with the real step and only a player's keys (it slows for crests that lead into corners and for jumps' speed caps, uses the air brake, and never starts a drift by accident), in 13 styles; every track must be finished cleanly by at least one, and the same inputs must replay to the same time. MED ([builder, gold, silver, bronze] in ms) comes from it: builder is the bot's best clean time rounded up to 10 ms, gold 1.05 ×, silver 1.2 ×, bronze 1.45 ×, rounded up to 0.1 s. Built-in tracks run 24–49 s for the builder time. The 20 tracks from the first version got longer layouts (layout 2) with the handling pass: a best from the first layout is kept in the save as pb[id].old = {t, m} (shown on the track card as "Old layout", and its medal m, worked out with OLD_MED, still counts). Looks: three environments of our own, chosen per track (th, or Look in the builder): Summer valley, Snowy pass, Desert canyon (THEME: sky, fog, ground, hills, and recolouring of trees, rocks and road sides). Ghosts: a ghost is a recording of inputs driven through the same physics once (GTrack, every step's pose kept, built a little ahead of where it's needed), so up to 10 race at once with almost no cost and never collide: your best (the Ghost setting) plus any you tick on a board. Online (signed in anonymously only when first used; the shared rules only let a player write their own node, which needs n, c, x, z, h, ts): rooms/rl-b2-<key>/players/<uid> is a player's board entry for a track (t best ms, k car, p paint, pa, a), rooms/rl-g2-<key>/players/<uid> the ghost behind it (t, g the inputs, f 2, v PHYS_V), where <key> is a built-in track's id or a custom track's trackKey and "2" is PHYS_V (new physics, new boards). A clean new best is uploaded if it beats the account's entry. The top 10 shows only entries whose ghost, fetched and driven again on this device, finishes cleanly in exactly the claimed time (Online.verify; results cached in rampline:vc), so a made-up time or an edited recording never shows. rooms/rl-tracks/players/<uid> holds a player's published tracks (tr: {<trackKey>: {nm, code, at}}, at most 5, at most 800 pieces each, and only after a clean finish of it), rooms/rl-plays/players/<uid> who raced which published track (p: {<trackKey>: [best ms, last played]}, 150 at most). Community lists published tracks (newest, most raced, raced this week), with the featured track of the week on top (the most raced in the last 7 days, else one picked by the week number), each with its own top 10; names and track names go through /namefilter.js. Hide and Report hide a track in this browser only (rampline:hidden); there is no shared report list, so Report tells the player to use Tell Arjun. Replay viewer: watch any of your last 12 runs (rampline:runs) or a board run, with play/pause, frame steps, ¼× to 2×, a timeline, and a graph of speed over distance that can lay a second run (your best or a board run) over the first. Builder: a free camera (middle-drag to look round, right-drag, Shift-drag or WASD to pan, wheel to zoom, Shift + wheel or Q/E for the build height), a palette (keys 1–0 for the first ten), click or tap to place (R turns it; the preview shows arrows where the road leaves), right-click, X then click, or the bin to delete, the select tool (V: click, Shift-click or drag a box) to move (M, or M over a piece to pick it up), copy (Ctrl+C), cut (Ctrl+X), paste (Ctrl+V, R turns the group, Esc cancels and puts a moved group back) or delete pieces, Ctrl+Z / Ctrl+Y undo and redo (200 steps), Look (theme), a check, Test drive (T), Share, and Publish (in Track builder's list). Share codes: RL1. + base64url of: version 1, name length + UTF-8 name (≤ 40 bytes), piece count (2 bytes), 5 bytes a piece (type, x + 128, z + 128, y, turn + 4 × walls), then an FNV-1a checksum (4 bytes); version 2 is the same with a theme byte after the version (theme 0 is still written as version 1, so older copies read it). decodeTrack checks everything and returns a plain message for anything wrong. Saved in the browser: rampline:save ({v: 1, pb: {<key>: {t, sp, n, name, lv, old}}, runs}), rampline:ghost:<key> ({v: PHYS_V, f: 2, t, g}: your best run as inputs, one byte per step (1 go, 2 brake, 4 left, 8 right, 16 back to checkpoint, 32 air brake) in runs of varint(count × 64 + input), base64url; version 1 ghosts used count × 32 and the old physics, and are left alone), rampline:runs, rampline:tracks, rampline:hidden, rampline:vc, rampline:set (vol, mute, cam, gfx, car, col, ghost), rampline:garage ({v: 1, car, col, pat, acc, rim, trail, name}: car, paint, pattern (plain, stripe, twin stripes, two-tone) and its colour, rims, exhaust trail (off, smoke, flame, rainbow) and the name on the boards; account-nudge.js syncs it) and keys under keybinds:rampline. Stats hooks: min('r2-<track id>', seconds) for every clean built-in finish (stats.js's LOWER; the old t-<id> names stay there for first-layout times), max('medals', n) (1 to 4 a track, old-layout medals included) and max('golds', n). Drawing: the track is one merged mesh, each gate banner its own mesh, the car one mesh plus its wheels as one InstancedMesh, each ghost the same plus a name tag, dust and the exhaust trail a point cloud each, speed lines one line set; with no ghosts racing is about 15 draw calls, with 10 ghosts about 41 (logic under 1 ms a frame). The camera widens its view and pulls back with speed, and speed lines fade in above about 90 km/h; the Speed effects setting (menu and pause, set.fx in rampline:set, on by default) turns both the widening and the lines off. On touch screens the page adds touch to body (steer bottom left, GO and BRAKE bottom right, BRAKE in the air is the air brake, checkpoint/restart/camera/sound/pause top right); the builder works by tapping, with one finger to look round, two to pan and pinch, and the select tool's bar (Move, Copy, Delete, Done) above the palette. A test hook (window.__rl) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/tidewick/: Tidewick (Back Room), a classic 8-bit style side-scrolling platformer for one player (Canvas 2D and Web Audio, no Firebase except stats.js). Pip, a child in a yellow raincoat, crosses a rainy harbour at dusk in two stages: HARBOR 1 (the quay, about 210 tiles) and HARBOR 2: THE BREAKWATER (a short stone fortress over dark water, about 100 tiles) with a boss at the end. Unlike the other Back Room games it isn't one self-contained index.html: it's ES modules (the owner asked for them) in js/, loaded by <script type="module" src="js/main.js"> with relative imports only, so it works from python3 -m http.server and GitHub Pages with no build step: constants.js (every tunable number: speeds, jump bands, gravities, air control, hitboxes, timers, scores, the boss), level.js (the stages and the cellar as rows of characters, with a legend at the top of the file: edit a row by hand, keeping every row of a map the same length), player.js (Pip's movement and collision), entities.js (enemies, power-ups, sparks, the Tollkeeper and its bolts), game.js (the states, scoring and how things touch; no DOM, so a Node script can import it and run the game headless), collision.js, camera.js, input.js, sprites.js (a 50-colour palette and every sprite as a pixel grid using at most three colours plus transparency, drawn to offscreen canvases at startup), render.js, hud.js (a 5 × 7 pixel font drawn in code, the HUD and the full-screen cards), audio.js (two pulse voices, a triangle and noise, synthesised; every tune is Tidewick's own) and main.js (the page, the loop, touch buttons). Loop: a fixed 60 updates a second in requestAnimationFrame (the number of updates is worked out from the time passed, so 60, 120 and 144 Hz screens run identically), a frame gap is capped at 100 ms, and the game pauses itself when the tab is hidden or loses focus. The picture is 256 × 240, drawn into an off-screen buffer and copied (no smoothing) to a canvas that holds the largest whole number of device pixels per game pixel that fits; that canvas is laid out at one CSS pixel per canvas pixel and shrunk by the device pixel ratio with a transform (a fractional CSS width, such as 341.33 px on a 3× phone, gets rounded by layout and stretches the picture by a pixel or two), so it is exact on every screen, including 3× phones and zoomed desktops. Physics follow the owner's spec to the number (pixels per frame): from rest Pip starts at 0.0742, walks up to 1.5625 (0.0371 a frame) and runs up to 2.5625 (0.0557), slows by 0.0508 with nothing held and skids at 0.1016 when pushing the other way, turning round below 0.5625; the jump's start speed and two gravities are picked at take-off from the speed (JUMP_BANDS; the lighter gravity only while jump is held and Pip is still rising), falls are capped at 4, air control has its own accelerations and a cap set by the take-off speed. A standing jump rises 66 px (4.1 tiles), a full-speed running jump 82.5 px (5.2). Head bumps hit only the block nearest Pip's centre and slide past a corner clipped by 4 px or less. The view only scrolls right and its left edge is a wall. Things: driftwood planks (small Pip bumps them, big Pip breaks them), lantern crates (a brass button, or a loaf of bread when small and an ember pepper when big), planks hiding up to 10 buttons for 4 s after the first hit, planks hiding a firefly jar (12 s of glowing invincibility with its own music), a hidden crate with a clover (an extra life), stone crates, clay chimney pots (the smoking one leads down to a cellar of buttons, whose flue brings Pip back up a second chimney), harbour water (costs a life), the signal mast (100 to 5000 points by grab height) and the harbour-master's hut, where the time left counts down at 50 points a second. Enemies: Bilge, a walking puddle (0.5 px a frame, turns at walls and at other enemies, walks off ledges, stomped flat for half a second); Whelk, a snail whose shell can be kicked (3 px a frame; a sliding shell knocks out other enemies on the shell chain, hurts Pip from the side, and a stomp stops it); and a wary Whelk with a coral shell that turns round at ledges. Stomps score 100, 200, 400 … 8000 then a life; shell knock-outs 500 … 8000 then a life. The Tollkeeper waits on a plank drawbridge at the end of the Breakwater: it paces 40 px either way, hops on a fixed random-looking rhythm (BOSS_HOP_GAPS), spits a slow brine bolt every 2 to 3 s (BOSS_SPIT_GAPS) at one of three heights (jump the low one; big Pip ducks the middle one), and its bolts start drifting in from off screen before it shows. Stomping it hurts. Get past it to the winch at the far end (the bridge drops it into the water) or hit it with 5 sparks; either way 5000 points, then the hut, the count, and an ending card. Dying in the Breakwater restarts the Breakwater. Saved in the browser only: tidewick:hi (best score) and tidewick:mute, both in try/catch; keys under keybinds:tidewick. Stats hook: max('score', score) at game over and at the ending (play time is counted by stats.js as usual). Controls: arrows or A/D move, down or S crouches and goes down a chimney, X or Space jumps, Z or Shift runs and throws sparks, Enter (or Esc) pauses, M mutes, all rebindable under Controls; backtick shows the debug view (hitboxes, position, speed, state, fps) and, while paused in it, P steps one update. On touch screens (body.touch): a pad bottom left (slide a thumb between left and right), RUN and JUMP bottom right, sound and pause top right; the picture sits above them upright and between them sideways. A test hook (window.__tw) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/gridpop/: Gridpop (Back Room), a single-player block-placement puzzle on an 8 × 8 board (one canvas, no libraries, no Firebase). The rules live between // ==CORE== and // ==/CORE== with no drawing or input: DEFS lists the 41 pieces, every orientation a separate piece because pieces never turn, each with a weight (pieces of 5 or more squares are about one draw in six); deal makes three independent weighted draws and only looks at the board to make sure an empty board gets something that fits, so sets are never rigged either way; applyMove places a piece, clears every full row and column at once (crossing squares once), scores 1 a square plus lineScore(n) (10, 30, 60, 100, 150, 210 for 1–6 lines, 5n(n+1)) times the combo, and deals a new set only when all three tray slots are empty. The combo goes up by one for each clear that comes within 2 non-clearing placements of the last one (combo, miss); a third miss resets it. Game over is checked after the clears, against every position of every piece left in the tray. Saved in the browser only: gridpop:save (the game in progress as {v:1, b: 64 digits 0–8, t: tray as [piece id, colour] or 0, s, cb, ms, mv, best}, written after every move and removed when the game ends; deserialize rejects anything out of range, unknown piece ids or a board with a full line, and a bad save just starts a new game), gridpop:best, gridpop:mute, gridpop:seen (How to play shown once), and keys in keybinds:gridpop (keybinds.js). Dragging (updateSnap, liftFor, slotAt): a press anywhere in a piece's third of the strip beside the board picks it up (a press in an empty slot's third takes the nearest piece left, if close), and the piece grows to board size at once. With a mouse it is centred on the cursor; on touch it rides 1.75 squares above the finger, the same everywhere, except that when the board reaches almost to the bottom of the screen (phones sideways) the lift shrinks over the last 2.5 squares so the bottom row can be reached with the finger still on the screen. It snaps by its centre to the nearest square; if that spot is blocked, the nearest free spot within about half a square more is used, a piece hanging a little off the edge counts as at the edge, and a chosen spot is kept until the piece is clearly nearer another (so it doesn't flicker or move as the finger lifts). The shadow (the piece's colour plus a white outline drawn over the dragged piece) is always exactly where it will drop, and there is no shadow when it can't drop; letting go drops it on the shadow or sends it back to the tray. Leaderboards hooks: BackRoomStats.max('score', …) (best score) and add('lines', n) (lines cleared). A test hook (window.__gp) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/2048/: 2048 (Back Room), the single-player number-sliding puzzle, drawn on one canvas (no libraries). The rules are pure functions at the top of the page script (slideLine, moveBoard, spawnTile, isOver); a board is an array of n × n numbers, row by row, with 0 for empty (every rule takes the size from the board's length), and linesFor(n) lists each direction's rows or columns starting from the wall being moved toward, which is where joins start (LINES is the 4 × 4 set). A move that changes nothing does nothing (no new tile, animation or score). A move made during an animation finishes that animation at once and then runs, so fast keys never lose a move or add an extra tile. Modes (picker under the buttons and in the menu; the last one played reopens): Classic is the 4 × 4 game; Mega is the same game on a 6 × 6 board (same spawn odds, win at 2048 with keep-going); College and Dessert are rule-identical to Classic (same engine, only the drawing differs) but each tile shows a step on a 12-step ladder from 2 to 4096 with its own colours, the number small in the corner, and a win at the 2048 step: colleges from ASU up to UChicago (2048) and Princeton (4096), a deliberately silly joke ranking with no basis beyond rough selectivity (the ladder is COLLEGE in the script), and desserts from a plain cracker up to a wedding cake (2048) and a croquembouche (4096), each with a small icon drawn with canvas shapes (DESSERT and ICONS); past the top of a ladder tiles show plain numbers. Strict mode (on by default; the setting was called Classic before the modes arrived, and its key is still classic) hides Undo; with it off there is a one-step Undo, and a game where Undo was used no longer reports to the leaderboard. Everything is saved only in this browser, one game and one best per mode: g48:game and g48:best for Classic (unchanged, so older saves carry over), g48:game:<mode> and g48:best:<mode> for mega, college and dessert (the game in progress is {v:1, b: board, s: score, w: reached 2048, wp: the 2048 screen is still showing, u: the undo step {b, s, w} or null, a: Undo was used, m: moves}, with 36 entries in b for Mega), g48:opts (classic, mute, mode) and keybinds:2048 (keybinds.js). Keep these keys and the v:1 format so saved games carry over; every read is checked (a save of the wrong size for its mode is ignored), and a broken save just starts a new game. Leaderboard hooks, sent only from games without Undo: BackRoomStats.max('best', score) and BackRoomStats.max('tile', biggest tile) for Classic, and best-<mode> / tile-<mode> for the others (best-mega, tile-mega, best-college, tile-college, best-dessert, tile-dessert; stat names can't hold underscores). The 2048 leaderboard has a chip for each mode's best score plus Classic's biggest tile (tile-<mode> is sent but has no board yet), and account-nudge.js saves g48:best and every g48:best:<mode> to the signed-in account. A webdriver-only hook window.__g48 exposes the rules, the mode switch and game controls for automated tests.

  • e63334287a3deaa3/stackfall/: Stackfall (Back Room), a single-player falling-block puzzle with two modes on one page (Canvas 2D, Web Audio, no Firebase). The rules are two pure engines with no DOM in them, Classic(opts) and Sand(opts), driven by update(ms, softDrop) and the move, rotate, hardDrop and holdPiece calls; the view only reads their state, and they report sounds and effects through onEvent. Classic: a 10 × 20 well plus 2 hidden rows, 7-bag randomizer (makeBag), SRS rotation with the published kick tables (KICKS_UP, one table for J/L/S/T/Z and one for I; KICKS is the same data converted to y-down), DAS 170 ms and ARR 50 ms, 500 ms lock delay that up to 15 moves or turns can reset (LOCK_MS, MAX_RESETS), gravity from the standard (0.8 − (level − 1) × 0.007)^(level − 1) s per row curve, a level every 10 lines, hold once per piece and a five-piece preview. Scoring is 100/300/500/800 × level for 1–4 lines (4 is called a "QUAD!"), T-spins by the 3-corner rule (0–3 lines 400/800/1200/1600, minis 100/200/400, all × level; the 5th kick test upgrades a mini), back-to-back × 1.5 for quads and T-spin clears in a row, combo +50 × combo × level, and 1 per soft-dropped row, 2 per hard-dropped row. It tops out when a new piece overlaps the stack or a piece locks entirely in the hidden rows. Sand: the well is a grain grid of 10·B × 20·B in typed arrays (B = 10, so 100 × 200, on computers; B = 8, so 80 × 160, on touch screens and small windows; every rule is in block units, so both play the same and grain counts and scores are normalised to 64 grains per block). Each piece is rigid and steerable (moves in half-block steps) until it touches the floor or the sand, then it crumbles into grains of its one colour (per-grain shade). step() is the cellular automaton: bottom row up, each grain falls straight down if it can, else slides diagonally down to a random free side, else rests, so each grain moves at most once a step; it runs at 22.5·B steps a second, up to 10 a frame. findClear() flood-fills (4-neighbour, one colour, Int32Array queue) from every grain on the left wall, and a component that also reaches the right wall clears whole: it flashes for 200 ms, a dissolve sweeps across it for 260 ms, and then all its grains vanish together so the sand above drops and can make another link. Points are the grains removed × the chain count in that cascade × (1 + 0.25 × (level − 1)), plus 300 × (colours − 1) × level when a new colour joins the chain; the chain resets once the sand comes to rest with nothing left to clear. The level goes up every 3,000 grains cleared (GRAINS_PER_LEVEL) and the fall speed is sandSpeed(level) blocks a second. Game over: the danger line is 2 blocks below the top (DANGER); a new piece only appears when no sand is above it, and if sand is still above it 1 s after the piece is due (SPAWN_WAIT_MS), the game ends. Sand mode has 4 colours by default, or 5 or 6. Saved in this browser only: best scores in localStorage stackfall:best:classic, stackfall:best:sand (4 colours), stackfall:best:sand5 and stackfall:best:sand6, plus stackfall:sandColours, stackfall:mute and the key bindings (keybinds:stackfall, via keybinds.js). At game over it reports BackRoomStats.max('classic' | 'sand', score) and max('lines', lines); stats.js has no READ entry for it yet. In an automated browser, window.__sf exposes both engines, the shape and kick tables, state(), layout() and start(mode) for the rule tests.

  • e63334287a3deaa3/melon-merge/: Melon Merge (Back Room), a single-player fruit-merging puzzle with its own 2D circle physics (no libraries, no Firebase). Fruit are dropped from above a glass jar; two of the same stage that touch merge into the next of 11 stages (cherry → watermelon) at their combined centre with their averaged velocity, growing to full size over 0.12 s so neighbours get pushed aside; two watermelons that touch both burst for MELON_BONUS (200). Drops are uniformly random from stages 1–5 (makeQueue). Merging into stage k scores SCORES[k] (1, 3, 6, … 66), and every merge in a chain counts. Game over when any fruit that isn't still falling in (it counts once it first lands, or after ENTER_GRACE 1 s) stays above the red line (DANGER_Y) for OVER_LIMIT 1.5 s in a row. The physics (makeSim) has no DOM in it: world units with the jar 520 × 720, a fixed 1/120 s step with 2 sub-steps, warm-started sequential impulses with friction and rolling, bounce only on a contact's first touch (so piles can't pump themselves up), then 4 position-correction passes (heavier fruit move less) and wall clamps; change RADII and the jar size together, since the danger line and drop height are measured against them. Only the best score is saved, in this browser under melon-merge:best (also melon-merge:mute and melon-merge:faces); a game in progress isn't saved. Keys are rebindable through keybinds.js (keybinds:melon-merge). Calls BackRoomStats.max('score', …) at game over and BackRoomStats.add('melons', 1) for each watermelon; leaderboard.js needs entries for those if they should show on the leaderboards.

  • e63334287a3deaa3/hivefix/: Hivefix (Back Room), a one-screen fix-it arcade game for one player (one canvas, Web Audio, no libraries, no Firebase except stats.js). Wax, a worker bee with a wax trowel, hops around a wooden hive frame of honeycomb cells, 5 across and 3 down in straight rows, patching them while Bruno, a bear's head, shoulders and paws looming over the top bar, walks along the top, raises both paws over his head over one or two columns (the telegraph: a red band and chevrons over those columns, at least 0.5 s) and pounds, shaking chunks of comb straight down those columns. Cells are whole (gold), cracked (one fix) or smashed open (two fixes); a fix takes 0.35 s and roots Wax to the spot; clear every cell for a "frame fixed" beat (500 plus a time bonus of 40 a second under par, par = 8 + 1.3 s a fix) and the next frame slides in with a new layout. The sky goes afternoon (frames 1–3), golden hour (4–6), sunset (7–9), dusk (10 on) (THEMES, themeFor). Rules live between // ==CORE== and // ==/CORE== with no DOM, drawing or sound (HF, created with HF.create(), stepped with HF.step(g) 120 times a second; Node scripts can run it headless): the grid is cells 0–14 row by row, hR[r*4+c] and vR[r*5+c] are the propolis ridges (a ridge blocks the hop across that edge), cell[] is 0 whole / 1 cracked / 2 smashed, sticky[] marks honey cells. Tuning is the constants at the top of the core (hop 0.1 s, out of a sticky cell 0.26 s, fix 0.35 s, 0.1 s input buffer, held direction repeats after 0.2 s then every 0.13 s, combo window 1.5 s up to x5, 3 lives, a hit freezes 0.25 s then 1.5 s of blinking, royal jelly lasts 5 s and gives 6 s of glow, Wax's hitbox is 26 units square on a ~46-unit sprite) and tune(level): speed = 1 + 1.4 × (1 − e^−(level−1)/10), capped at 2.1 (about +0.12 a frame early on, 2.05 at frame 15), which scales chunk fall speed (170 × speed units a second), Bruno's walk and his rest between slams; the paws-up wind-up is 0.8 s on frame 1, 0.025 s shorter each frame, never under 0.5 s; the chance he aims at Wax's column rises from 0.3 to 0.65; a slam hits a neighbouring column too from frame 2. New things: frame 2 ridges (2, then one more every two frames, up to 6) and royal jelly (a nurse bee leaves a drop in a whole cell; grabbing it gives 6 s of golden glow, immune to chunks and wasps, +300, and +50 for each chunk that bounces off); frame 3 wasps (an arrow and a buzz at the edge of the row 1 s before one crosses it); frame 5 sticky honey cells (hopping out takes 0.26 s); frame 6 double pounds; frame 8 wasp pairs; frame 10 aftershocks (a slam can re-crack a whole top-row cell while 3 or more cells are still broken). A new frame (genFrame) breaks 7 + level cells (up to all 15), 2 + 0.6 × level of them smashed, picks sticky cells at random and adds ridges one at a time, keeping one only if every cell still has a sideways and an up-or-down way out and all 15 cells can still be reached. Fairness: before Bruno raises his paws, or a wasp's arrow shows, the core works out (check, solve) whether, with that threat added, every cell that had a way out of everything already announced still has one (a path of waits and hops, checked in 1/120 s ticks for the next 5 s, counting the cells and the edge a hop sweeps through, and the fix or hop Wax can't break off); if not, Bruno holds his paws a beat (or picks another column) and the wasp waits. Each frame is seeded (mulberry32 in g.seed), so the same seed plays the same. Drawing: every sprite (cells, ridges, honey, Wax, the nurse bee, wasps, Bruno's four faces, paws, arms, chunks, particles, the four backgrounds) is drawn once to an offscreen canvas at the current pixel scale and only redrawn when the window size changes; the loop draws with interpolation between steps, the frame gap is capped at 0.1 s, chunks, particles and pop-ups are fixed pools, and the canvas is sized for the device pixel ratio (up to 2). The game pauses when the tab is hidden or the window loses focus. Saved in the browser only: hivefix:best, hivefix:bestFrame, hivefix:mute, hivefix:shake (all in try/catch), keys under keybinds:hivefix. Stats hook: max('score', score) and max('frame', frame reached) at game over (the Leaderboards show best score; play time is counted by stats.js as usual). Controls: arrows or WASD hop (hold to keep hopping), Space, X or Z fix (hold to keep fixing), Enter or Esc pause, M sound, all rebindable under Controls; Screen shake on/off on the title and pause cards. On touch screens (body.touch): a d-pad bottom left (slide a thumb between directions) and a FIX button bottom right, beside the frame when the phone is sideways; swiping on the frame also hops, tapping Wax's own cell fixes and tapping a neighbouring cell hops there. A test hook (window.__hf) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/daily/: Daily Puzzles (Back Room), the dashboard for every daily puzzle. No libraries, no network except stats.js. DAILY_GAMES at the top of the script lists every daily game (slug = folder = storage key daily:<slug>, name, icon, cat: word, geo, logic or soccer, a one-line blurb, ready), plus EXTRA link cards under Play together (Puffer Party, chip in place of a daily status). Tiles only show for ready:true games, and "Coming soon" ones are listed under each category; flip ready when a game's folder lands. For each game it reads the shared record daily:<slug> (written by the game when a daily ends: { v:1, days:{YYYYMMDD:{done,win,score,tries,time}}, played, wins, streak, maxStreak, last, dist }) and shows today's status (Not played, Solved in N / in m:ss, Done, or Missed) and the game's 🔥 streak (only while its last win was today or yesterday). Deepword is read from its own deepword: keys. The summary counts today's finished puzzles, the overall streak (days in a row, ending today or yesterday, with at least one daily done, worked out from every game's days), and counts down to local midnight; when every ready game is done it shows an "All done today" banner. stats.js (data-game="daily"): max('streak', overall) and add('done', 1) once per game per day (remembered in daily-hub:counted). It re-reads storage when the tab comes back into view or another tab writes, so finishing a puzzle in another tab updates it. Test hook: window.__dh under webdriver (setToday, render, streakOverall). A game whose win shouldn't read "Solved in " gives a show template ({tries}, {score}, {time}; {score|hint} adds the word, plural unless the number is 1, and zero is the text used when the number is 0): Crosslet shows its time, Ninefold puzzles solved of 3, Crossbar squares, Stat Duel calls right, Top Bins entries found, Quartets mistakes (Perfect for none), Sleight hints and Poopdle moves. A missed puzzle's tile has the finished look but no ✓. Poopdle is under Logic. The daily games don't have Back Room or phone-page cards of their own: the Daily Puzzles card and tile are the way in (the phone thumbnails in m/thumbs/ are kept for if they ever do).

  • e63334287a3deaa3/hunch/: Hunch (Back Room, a daily puzzle on the Daily Puzzles page), a five-letter word guess in six tries, like Wordle. No libraries, no network: words.js holds window.WORDS5 = { ANSWERS, GUESSES } as space-separated strings, 2,019 answers (common, school-safe, no plurals) and 6,334 more allowed guesses (8,353 in all), written for the site and checked by a script (five letters a–z, no repeats, every answer also a guess, a blocklist for rude words). ANSWERS is already in daily order (a fixed seeded shuffle): day n after 1 November 2026 (puzzle #n+1, from the player's local date) uses ANSWERS[n % length], so new answers must be appended to keep past days unchanged. Scoring marks greens first, then yellows only for as many copies as the answer has left. Hard mode (Settings, only before a game's first guess) makes every green stay put and every found letter be reused; High contrast swaps green/yellow for orange/blue. Unlimited mode picks random answers and never touches daily stats. Storage: the shared daily record daily:hunch ({ v:1, days:{YYYYMMDD:{done,win,score,tries,time,hard?}}, played, wins, streak, maxStreak, last, dist }, 60 days kept; the Daily Puzzles page reads it), the day in progress hunch:day ({ d, g:[guesses], hard, t0 }), Unlimited hunch:free, settings hunch:settings, hunch:seen (how-to shown) and hunch:mode. stats.js (data-game="hunch"): add('wins'), max('streak'), min('fewest'). The page is generated from the same engine as Fourcast (CFG at the top of the script: boards, rows, offset); keep the two in step when fixing the engine. Tile size is worked out in fit() (bigger of the layouts that fit); on a sideways phone the keyboard sits right of the board. Test hooks: window.__wg under webdriver (setToday('YYYYMMDD'), type(word), score, …).

  • e63334287a3deaa3/fourcast/: Fourcast (Back Room, a daily puzzle), four five-letter words at once in nine guesses, like Quordle. The same engine as Hunch with boards: 4, rows: 9, offset: 777, and the same word lists (it loads /e63334287a3deaa3/hunch/words.js, so don't move that file). Each guess goes on every unsolved board; a solved board stops. Day n uses ANSWERS[(777 + 4n + k) % length] for boards k = 0–3, so appending answers keeps past days. Each keyboard key shows four corner colours, one per board in reading order (top left = board 1). Tiles may be up to 1.45× wider than tall, and fit() picks a 1 × 4 or 2 × 2 layout, whichever gives bigger tiles; on a sideways phone the keyboard stays underneath in short rows. Win = all four within nine; the share card shows the guess each board fell on (🟥 for missed). Storage: daily:fourcast (same contract; each day also has boards, how many were solved), fourcast:day, fourcast:free, fourcast:settings, fourcast:seen, fourcast:mode. stats.js: add('wins'), max('streak'), min('fewest').

  • e63334287a3deaa3/wordplay/: shared files for the three daily word games (Quartets, Crosslet, Sleight). kit.js sets window.Kit: localStorage wrapped in try/catch (Kit.store), the local date and puzzle number (Kit.today(), Kit.dayNum(), 2026-11-01 is #1), Kit.pick(name, length, day, inOrder) (the daily puzzle: a seeded shuffle of the list, a new shuffle each time round and never the same puzzle twice across the seam; with inOrder the first time round follows the list as written), the daily:<slug> record the Daily hub reads (Kit.Daily.load/finish, which also calls BackRoomStats.add('wins') and max('streak')), and the page shell (top bar with Back to the Daily hub, How to play on the first visit, Stats with distribution and countdown, toast, share). kit.css is the shared look (Deepword's and the geography games' colours, fonts, pills, modals, on-screen keyboard). Each game saves the day's progress in <slug>:day and whether How to play was seen in <slug>:seen. A test hook reads window.__WP_TODAY (YYYYMMDD) only when navigator.webdriver is set. Adding, removing or reordering puzzles in a game's data file changes which puzzle future days get (a game already started today keeps its puzzle).

  • e63334287a3deaa3/quartets/: Quartets (Back Room), a daily "find the groups" game like Connections: 16 word tiles hide four groups of four, coloured yellow, green, blue and purple from the most straightforward to the trickiest; four mistakes allowed, "One away…" when three of four belong together, repeated guesses don't count, shuffle and deselect; found groups slide to the top as coloured bars with their category, and on a loss the rest are shown one by one. Keyboard: click words, Enter submits, Esc deselects. puzzles.js holds 125 puzzles written for the site ([category, [four words]] × 4, in colour order); every word is in exactly one group and the red herrings were planned so each puzzle splits only one way. Tile order for the day is seeded from the date, so everyone starts with the same board. Hooks: add('wins'), max('streak'), add('perfect') on a daily win with no mistakes.

  • e63334287a3deaa3/crosslet/: Crosslet (Back Room), a daily mini crossword like the NYT Mini: 5 × 5 grids with black corners (and some open 4 × 4s), the clue bar above the grid (tap it to switch direction), the clue lists beside the grid on wide screens, an on-screen keyboard on touch screens; letters auto-advance, Backspace steps back, arrows move (a sideways arrow first switches direction), Space switches, Tab/Enter go to the next clue; Check and Reveal by square, word or puzzle; a timer that starts at the first letter and pauses when paused, hidden or behind a dialog. A daily "win" is a clean solve (no reveals); the best clean daily time is kept in crosslet:best. puzzles.js holds 64 grids (g rows with # for black squares, a/d clue lists in number order). The grids were filled by a one-off script from a list of about 3,000 common 3- to 5-letter words (with awkward, rude and touchy words removed); every entry is at least 3 letters, every square is in an across and a down word, and all 530 different entries have clues written for the site. The first time round, days follow the file's order. Hooks: add('wins'), max('streak'), min('fastest', seconds) on a clean daily solve (fastest is in stats.js LOWER).

  • e63334287a3deaa3/sleight/: Sleight (Back Room), a daily cryptic clue like Minute Cryptic: letter boxes with the letter count, five hints taken in order (1 underlines the definition, 2 names the kind of wordplay and its indicator, 3 explains the wordplay, 4 gives the first letter, 5 shows the answer), wrong answers cost nothing; the score is hints used (lower is better) and showing the answer counts as a loss. Afterwards the clue is shown with its definition underlined and indicator dotted, and a breakdown of how it works. clues.js holds 128 clues written for the site (c clue, a answer, d definition, t type, i indicator, p explanation), covering anagrams, hidden words, reversals, charades, containers, homophones, deletions, double definitions, first letters and &lits; every anagram, hidden word, reversal, container and deletion was checked by script against its answer. The first time round, days follow the file's order, which goes from easier to harder. Hooks: add('wins'), max('streak'), add('clean') on a daily solve with no hints.

  • e63334287a3deaa3/sem-data/: shared files for the two daily meaning games (Nearword, Warmer). Closeness in meaning comes from real word vectors: GloVe 6B 300d (Stanford NLP, Wikipedia 2014 + Gigaword 5, Public Domain Dedication and License); nothing is computed in the browser except a lookup, and nothing is loaded from other sites. words.txt: the 22,101 guessable words, one per line, alphabetical (line n = word number n): common lowercase dictionary words from SCOWL (sizes ≤ 40, via the npm package wordlist-english, MIT; SCOWL's notices are in SCOWL-Copyright.txt) ordered by how common they are in GloVe's corpus, the first 22,000 plus a hand-picked list of everyday words news text undercounts (kitten, crayon, snowman…) and every secret word; no names or abbreviations, and rude words, slurs and sexual words are left out. A word ending in * (214 of them: drink, drugs, weapons, violence, insults and a few borderline words) can be guessed but the game never shows it by itself (hints and the closest-words list skip it). d/000.bin … d/429.bin: one file per secret word (430, about 11 KB each, 4.8 MB in all; a game downloads only the one it plays), zlib-compressed: the 1,000 words nearest the secret in exact order with their similarity (cosine × 10000), and for every other word a "level" by rank (1,001–1,200, 1,201–1,400 … 13,001–22,101) with each level's average similarity; the format is described at the top of tools/build.py. Ranks past 1,000 are shown as the middle of the level (Nearword shows them with ~) and scores past 1,000 as the level's average (Warmer shows one decimal), so they are approximate. The secret word is the first word in its file; files have no names or words in them that give the answer away, but anyone reading the files could find it, as with any game like this. sem.js sets window.Sem: the daily choice (puzzle #1 is 2026-11-01, local date; days walk through the files in order, which the build shuffled, then a new seeded shuffle each 430-day round; Warmer runs 215 files ahead of Nearword so they never share a word on a day; the count is COUNT in sem.js and must match the files), loading words.txt and a day file (it uses the browser's DecompressionStream and has its own small inflate for older browsers), the daily:<slug> record, and Sem.game(cfg), the whole game page both games share: top bar, How to play, Stats with countdown and share, the guess box (Enter to guess; on touch screens an on-screen keyboard with an "abc" key for the phone's own keyboard), the latest guess on its own and the sorted list, Hint (adds the word ranked about halfway between your best guess and #1, never the answer), Give up (a loss), and after the game the 100 closest words. Progress is saved in <slug>:day and, for Unlimited, <slug>:free (by word, so it survives a rebuild of the word list); <slug>:seen marks How to play as seen. sem.css is the shared look (Deepword's colours and fonts). tools/build.py rebuilds words.txt and d/ from the downloaded vectors and word lists (instructions at its top; the vectors are 480 MB and aren't in the repo). Rebuilding with a different word list or secret list changes future daily words and makes saved games of the day restart, so do it rarely; with the same inputs it gives byte-identical files.

  • e63334287a3deaa3/nearword/: Nearword (Back Room), a daily "find the secret word" game like Contexto: every guess shows its rank among all the words by closeness in meaning to the secret (the secret is #1), with a bar that is green in the top 300, yellow to 1,500 and red beyond. Unlimited guesses, hints and give up. Hooks: add('wins'), max('streak'), min('guesses') on a daily win.

  • e63334287a3deaa3/warmer/: Warmer (Back Room), like Semantle: every guess gets a similarity score (cosine × 100, the secret is 100) and, inside the 1,000 nearest words, a nearness out of 1000 (999 = the nearest); temperatures run 🧊 Freezing, ❄️ Cold, 🌥️ Cool outside the 1,000 and 🌤️ Tepid, ☀️ Warm, ♨️ Hot, 🔥 Burning inside. Unlimited guesses, hints and give up. Hooks: add('wins'), max('streak'), min('guesses') on a daily win.

  • e63334287a3deaa3/ninefold/: Ninefold (Back Room), a daily sudoku: three new puzzles every day (Easy, Medium, Hard) plus Unlimited. sudoku.js (sets window.Sudoku, also runs in Node) makes and grades every puzzle in the browser, no libraries: a seeded random full grid (mulberry32 seeded by an FNV hash of the text seed, the same as the geography games, so every device makes the same grid), then clues taken out in mirror-image pairs (cell i and 80 − i) in a seeded order, each removal kept only if the puzzle still has exactly one solution (a quick brute-force count) and a person-style solver can still finish it with the level's techniques. Tiers: Easy = naked and hidden singles only, at least 36 clues; Medium = also pointing, box/line, naked/hidden pairs and triples, at least 26 clues; Hard = also X-Wing, XY-Wing, Swordfish, XYZ-Wing and naked/hidden quads. A puzzle is used only if its hardest needed technique is exactly its level's tier (otherwise the next seeded attempt is tried), so nothing ever needs guessing and every hint has a reason. Daily seed: ninefold-YYYYMMDD (level and attempt number are added), so changing sudoku.js changes future daily puzzles; today's puzzles are cached in ninefold:cache and each saved game keeps its own puzzle string, so an update mid-day doesn't break games in progress. Generation takes about 1/15/30 ms (Easy/Medium/Hard) on a computer, under half a second on a slow phone. Hints: if a number on the board is wrong, the hint takes it out; otherwise the solver works from the current board and fills the next cell it can prove, explaining each step it used (in my own words). Storage: daily:ninefold (see the daily contract; score = puzzles finished that day, lv = seconds per level), ninefold:day:<level> and ninefold:free:<level> (the game in progress: puzzle, numbers, notes as 9-bit masks, hinted cells, seconds, hints, mistakes, undo list), ninefold:levels (solved count, total seconds, best time without hints per level), ninefold:set (settings: show mistakes, tidy notes, highlight matching numbers, highlight row/column/box, show timer), ninefold:lv (last level), ninefold:seen. The clock runs while the puzzle is on screen and no window is open; a day counts as played from the first move. Keys: arrows/WASD move, 1–9 fill, Shift+number or N for notes, Backspace/0 erase, Z or Ctrl+Z undo, H hint. Hooks: add('wins') per daily puzzle, max('streak'), min('fast-<level>', seconds) for hint-free solves.

  • e63334287a3deaa3/poopdle/: Poopdle (Back Room, Nico's idea), a daily word ladder like Weaver: change one letter at a time from the day's start word to POOP, every step a real word. words.js has 3,106 four-letter words: 1,909 everyday words (start words and hint words) and 1,197 rarer ones that are accepted too. Built once from sindresorhus/word-list and pyspellchecker's English list (both MIT), ordered by wordfreq frequency, then checked by hand: no names, abbreviations, swearing, slurs or grown-up words (POOP and harmless silly words like BURP and FART stay; it's the joke). ladder.js (sets window.Ladder, also runs in Node) finds shortest ladders by breadth-first search over all accepted words, so par can't be beaten. Daily start words are everyday words whose shortest ladder to POOP is 4 to 8 steps and can be done with everyday words only (738 words); they come round in a seeded shuffle (poopdle-cycle-<n>), so none repeats for two years. Puzzle #n is the n-th word of that order; changing either word list changes future start words. Undo goes back a word but the move still counts; Hint first names the letter to change, then a word on a shortest ladder (everyday words first); Give up (tap twice) shows a shortest ladder. Letters already matching the goal are green. Unlimited has random start words and a "Goal: any word" switch for random goal words. Storage: daily:poopdle (score = moves over par), poopdle:day and poopdle:free (the ladder, moves, emoji log, hints, times), poopdle:any, poopdle:seen. Share: one square per move (🟩 closer, 🟨 no closer, 🟥 further, ↩️ undo, 💩 arrival), no words. Hooks: add('wins'), max('streak'), add('perfect').

  • e63334287a3deaa3/geo-data/: shared files for the four daily geography games (Hotspot, Outline, Unfurl, Overland). No libraries and nothing loaded from other sites at run time. countries.js sets window.GEO_DATA.list, one row per guessable place (231): [id, name, aliases, continent, label lon, label lat, level, area km², neighbours]. id is the ISO 3166 two-letter code in lower case (xk Kosovo, gf French Guiana); aliases are the other names people type, separated by | (UK, USA, Holland, Ivory Coast, Czech Republic, Burma, DRC…); level 2 = an answer in every game (164 sovereign states), 1 = an answer in Unfurl only (30 tiny or scattered island states and microstates, whose shapes are too small to play with), 0 = guessable but never the answer (territories such as Greenland, Puerto Rico and Hong Kong, and the disputed or partly recognised Kosovo, Taiwan, Palestine and Western Sahara); neighbours are land borders. shapes.js sets window.GEO_SHAPES: each place's outline as rings of longitude/latitude in 1/1000 degree, delta-encoded like the Google polyline format (Geo.rings(id) decodes it); ids starting with _ are only drawn (Antarctica, the Siachen glacier, and France's and the Netherlands' Caribbean and Indian Ocean islands). Both files were generated from Natural Earth 1:50m admin-0 countries v5.1.2 (public domain): borders were simplified as a topology (so neighbours' borders stay identical; small countries keep relatively more detail), islands smaller than 60 km² or 0.04% of a country's land were dropped (the biggest piece of every place is always kept), Northern Cyprus was merged into Cyprus and Somaliland into Somalia, France's overseas parts were split off (French Guiana became its own place, so France doesn't border Brazil), and neighbours are countries sharing a border line in that data, plus Zambia–Botswana (the short Kazungula border the 1:50m map misses). There's no Spain–Morocco border, because Ceuta and Melilla are too small for the 1:50m map. Regenerating the data is a one-off Node script (topojson-server/-simplify), not part of the site. flags/ holds the 194 answer flags as SVG, copied unchanged from flag-icons 7.6.0 (MIT; the licence is in flags/LICENSE), 4:3 versions, 1.3 MB in all but each game loads only the one it needs. geo.js sets window.Geo: name matching (norm ignores case, accents, punctuation, "the" and "St."/"Saint"; find matches a name or alias exactly; suggest ranks names starting with the text, then words starting with it, then anywhere in it), distances (km between label points, bearing the rhumb-line direction, borderKm between the nearest points of two outlines, 0 for neighbours), the daily choices, the daily:<slug> record, the page shell (top bar, How to play, Stats with countdown, share), the guess box (suggestion chips; Enter takes the highlighted one; on touch screens an on-screen keyboard and inputmode="none", with an "abc" key to switch to the phone's own keyboard) and Geo.Globe, a canvas globe in orthographic projection (drag to turn, pinch, scroll or +/− to zoom, tap to pick a country; parts of a country behind the globe are pushed to the rim). six.js is the six-guess game Outline and Unfurl share. geo.css is the shared look (Deepword's colours and fonts). Daily choices: puzzle #1 is 2026-11-01 (local date). Hotspot and Outline walk through one shuffled order of the 164 level-2 countries (a new shuffle each time round, Outline 59 places ahead, so they never match on a day and nothing repeats within 164 days); Unfurl has its own order of the 194 flags and skips ahead if it would match either. Overland walks through a shuffled list of all 2,920 pairs of level-2 countries joined by land with 3 to 6 countries in between on the shortest route, so a pair doesn't come back for eight years. Changing countries.js (pools or neighbours) changes future daily puzzles, so do it rarely. Every game saves its progress for the day in <slug>:day and whether How to play was seen in <slug>:seen. A test hook reads window.__GEO_TODAY (YYYYMMDD) only when navigator.webdriver is set.

  • e63334287a3deaa3/hotspot/: Hotspot (Back Room), a daily "find the mystery country" game like Globle: every guess is coloured on the globe from cream (far) to deep red (next door) by the distance between the nearest points of the two borders, the list keeps the nearest at the top, and guesses are unlimited (give up is offered after 10). Small countries get a ring on the globe; tapping a guessed one or its row shows it. Hooks: add('wins'), max('streak'), min('guesses') on a daily win.

  • e63334287a3deaa3/outline/: Outline (Back Room), like Worldle: name the country from its outline (drawn in an equal-area projection centred on it, leaving out small far-off islands such as the Canaries or Hawaii) in six guesses; each wrong guess shows the distance between the countries' label points, an arrow towards the answer and a percentage (100% minus distance over half the world's circumference). After the game the answer is shown on the globe. Hooks: add('wins'), max('streak'), min('tries').

  • e63334287a3deaa3/unfurl/: Unfurl (Back Room), like Flagle: the flag starts under six tiles with one lifted (the order is shuffled from the day) and each wrong guess lifts another; six guesses, with the same distance, arrow and percentage as Outline. Loads only countries.js and the one flag; the globe at the end loads shapes.js then. Hooks: add('wins'), max('streak'), min('tries').

  • e63334287a3deaa3/overland/: Overland (Back Room), like Travle: get from the start country to the end one by naming countries joined by land borders; you have the shortest route's length plus 4 guesses and win as soon as your countries join up. Guesses are green (on a shortest route), yellow (a detour of one or two countries) or red (off track or no land link); at the end one shortest route (alphabetical at ties) is shown on the map. Hooks: add('wins'), max('streak'), add('perfect') when the route is as short as possible.

  • e63334287a3deaa3/football-data/: shared data and code for the daily soccer games (Deadline Day, Journeyman, Scout Report, Crossbar, Teamsheet, Back Four, Stat Duel, Top Bins). players.js sets window.FOOTBALL: LEAGUES (country of each league; Monaco counts as France, Swansea/Cardiff as England, Canadian MLS clubs as MLS), NATIONS ([name, continent]), CLUBS ([name, league, colour 1, colour 2]: clubs are drawn as two-colour chips we picked, never badges), and one row per player: [name, nation, GK/DF/MF/FW, birth year, shirt number at his main club (0 = not sure), career, aliases, extra]. The career is first-team spells in order, CODE start-end; with ~ for loans, 2023- for "still there at the start of 2025-26", and a + token meaning the list is incomplete (those players stay out of Journeyman and Back Four). Extra facts: main: (main club when it isn't the longest spell), wc: (World Cup-winning squads, up to 2022), bdo: (Ballon d'Or, up to 2025), caps:/ig: (men's caps and goals, only for players who have finished with their country), plg: (Premier League goals, only for players who have left it). Everything is as of 2025 and only facts we were sure of; anything doubtful was left out. kit.js (window.FBK) builds the top bar, How to play and Stats modals, the player picker (accents, case and punctuation ignored; unique surnames and listed aliases like "Mbappe" or "CR7" work), shares, and the storage contract: daily:<slug> = {v:1, days:{YYYYMMDD:{done,win,score,tries,time}} (60 days), played, wins, streak, maxStreak, last, dist}; the daily puzzle is dailyIndex(n, slug), a fixed shuffled order walked one per day from 2026-11-01 (#1), so nothing repeats until every puzzle has been used. cats.js (window.FBCAT) defines the grid/group categories (c: club, n: nation, l: league country, wc, wy: World Cup year, bdo, d: birth decade, y: birth year, p: position). After any data edit run node e63334287a3deaa3/football-data/check.js: it checks clubs/nations, careers in order, that every name and alias finds its player, that each transfer matches the career, that every Crossbar square has 3+ answers and every grid can be filled with 9 different players, that Back Four groups are exclusive, line-ups have 11 matching the formation, and that no daily repeats too soon. crossbar/grids.js and back-four/puzzles.js were generated from players.js; if you change a player, the checker tells you whether any of them broke. A test hook (window.__fb, window.__game, and ?day=YYYYMMDD to fake today) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/deadline-day/: Deadline Day (Back Room, a daily soccer puzzle): a famous transfer (from, to, year, reported fee from transfers.js, 169 of them) → name the player in 6; misses unlock nationality, position, age that year and initials.

  • e63334287a3deaa3/journeyman/: Journeyman (Back Room, a daily soccer puzzle): a career revealed club by club (183 players with complete careers, unique club paths) → name him in 6; "show next club" costs a guess.

  • e63334287a3deaa3/scout-report/: Scout Report (Back Room, a daily soccer puzzle): a mystery player (294 with a known shirt number) in 8 guesses; each guess compares nation (yellow = same continent), league and club of each player's main club (yellow = the mystery player also played there), position, birth year and shirt (yellow within 2, arrows).

  • e63334287a3deaa3/crossbar/: Crossbar (Back Room, a daily soccer puzzle): 3×3 grid (404 in grids.js), 3 lives, one player per square, each player once; afterwards every square lists all players in our list who fit ("1 of N").

  • e63334287a3deaa3/teamsheet/: Teamsheet (Back Room, a daily soccer puzzle): a starting XI from a World Cup, Euros or Champions League final (42 in lineups.js) with one listed player hidden (344 puzzles); 5 guesses, hints born year, country/main club, initials.

  • e63334287a3deaa3/back-four/: Back Four (Back Room, a daily soccer puzzle): 16 players into four groups (418 in puzzles.js), 4 mistakes, "one away" hint, colours easiest to hardest.

  • e63334287a3deaa3/stat-duel/: Stat Duel (Back Room, a daily soccer puzzle): higher or lower on finished totals (international caps or goals of players retired from international football, Premier League goals of players who left it); daily run of 10 (ends on a miss) built from the date, plus an endless unlimited streak.

  • e63334287a3deaa3/top-bins/: Top Bins (Back Room, a daily soccer puzzle): name a whole list (14 in lists.js: Ballon d'Or winners by decade, most Ballon d'Or wins, World Cup Golden Ball / Golden Boot, Euro and Copa América winners, first ten European Cup winners, Premier League all-time scorers at the end of 2024-25, England's top scorers, Spain's and Italy's most-capped, Premier League champions, Zlatan's clubs) in 3 minutes; the daily list repeats every 14 days.

  • e63334287a3deaa3/party.js: Parties (Back Room). A Party panel above the games: create a party (5-character code) or join one, up to 8 people; the person who has been in it longest is the leader, picks a multiplayer game and presses Play together, and every member's browser opens that game in a new room (#<prefix>-p<6 random characters>) and joins it. Back returns everyone to the party. Party invite links are /#pt-<code>: the home page holds them until the Back Room code is entered, then joins the party instead of opening a game. Works on the phone version too (m/index.html loads party.js, lends it its openGame, and sends #pt- invites to the party instead of Rumble Karts), and both after the home-page reveal (the home page's reveal() loads party.js from the Back Room folder and lends it openGame through window.BackRoom) and on the Back Room page opened directly (a <script> tag; games then open in party.js's own layer). Storage: Firebase Realtime Database, same project and rules as Rumble Karts, at rooms/pt-<code>/players/<uid>. The rules only let a browser write its own node and require the same fields the games write (n, c, x, z, h, ts; x/z/h are always 0), so the party keeps everything there: j (join time, which decides the leader), pk (game the leader picked), go (the leader's latest launch: game, room, time) and kick (leader only: the people it removed, [{ u: uid, t: time }], up to 8, kept for 10 minutes), pub (1 while the leader's party is Public, which it is unless the leader switches it to Private; the choice is kept in localStorage party:pub) and pn (party size, leader only, while public). On the Back Room page itself (no home-page layer), the Play links of the party games open in party.js's own layer instead of replacing the page, so the Online and Playing now panels keep running and others can join you; a click with Ctrl/Cmd/Shift still opens the link normally. Party.launch takes any room code a game's own room box takes (letters, digits and hyphens, so Quick match rooms like pub-2 and Rumble Karts' lobby-2 too), and Party.priv says whether you're in a party its leader set to Private. Nodes are removed on disconnect and ignored after 2 minutes without a heartbeat. The leader sees a small ✕ on every other member; because it can't delete their node, pressing it (after a confirm) lists them in its own kick. Every member watches the current leader's list (a list in anyone else's node is ignored), and one that finds its own uid there with a time from the last 10 minutes leaves by removing its own node and shows "You were removed from the party by "; the leader's row shows "Removing…" until the node is gone. Joining checks the leader's list the same way, so a kicked person can't come back for 10 minutes while that leader is still the leader (a new leader starts with an empty list). Supported games: Shell Crackers, Rumble Karts, Slipstream, Boostball (6 max), Coil, Showdown, Backrooms 1v1–4v4, Chromaflage, Spire (12 max), Turbo Trail (12 max), Escapade (12 max), Updraft (12 max), Odd One Out (16 max), Tumble Trials (16 max), Deepword (12 max) and Puffer Party (12 max). Trivium isn't supported (its contests get their code from the host's screen). party.js joins the game for each member by filling in the game's name and room boxes and pressing its own Play in room button (#name / #room / #joinRoom), so the games themselves weren't changed: keep those ids if you rework a game's menu. Party launches always use the member's party name, even over a name the game had saved or made up (Shell Crackers' Egg123, Rumble Karts' Driver123, Backrooms' Wanderer123, Hail Mary's random team), so everyone shows up under the name the party knows them by. Because the games save whatever is in their name box, party.js then puts the game's previously saved name back in its localStorage key (right after joining, again 3 and 15 seconds later, and when the game closes), unless the player has typed a different name since; a game that had no saved name keeps the party name. To add a game, add a row to GAMES in party.js (prefix, folder, room size, nk for the localStorage key the game saves its name under, and ids if its menu uses other ids). At the end of party.js, window.Party lets the Online panel (social.js) share its sign-in, read the party code, size, leader (leader: uid and name), member uids and max, create or join a party, and open a game room; it doesn't change how the Party panel works. Party.roster (members with their party names) and Party.out (uids on the current leader's kick list, for the 10 minutes it lasts) are there for the party chat. The Party panel has a Party chat button (once someone else is in the party) that opens the Online panel's Party chat. Signing in waits up to 25 s here, in feedback.js and in the online games' Net.init (Rumble Karts and Backrooms have no limit; the others' menus say "Still signing in…" after 8 s and put their message back once signed in), because a slow phone on school Wi-Fi can take 10-20 s. Each member's card shows their avatar (avatar.js) and, on its own line, their status tier badge (miles.js) when those scripts are loaded; without them, a coloured initial and no badge.

  • namefilter.js (site root): the name filter. Every page where players pick a name others can see loads it with a plain <script src="/namefilter.js"></script> right after <meta charset> (every Back Room game, the Back Room and phone pages, Hype Cycle Exchange, Boxed and the home page; not the admin page). NameFilter.clean(name) masks rude words at the start of a word after their first letter (F***face), the same way and with the same look-alike digits as social.js's and leaderboard.js's mask, and turns a name that hides one of the worst words some other way (f.u.c.k, fuuuck, xfuckx) into Player; clean names come back unchanged. On its own it cleans the name boxes the games use (#name, #inName, #joinName, #hostName, #newName, #ptyName, or any input[data-name-filter], such as the Online name box) on load, on blur or change, and on any click, Enter or submit before the game's own handlers run, so Play, Join and Host always read a cleaned name; it doesn't change a name while it's being typed. social.js, party.js, leaderboard.js and stats.js also pass every name through it in their cleanName, so names other people already saved are masked where those panels show them. A new game gets it by using one of those ids for its name box (or data-name-filter) and adding the script tag. The word lists are stored in ROT13; the comment at the top of the file says how to add words, and which list also looks for words hidden inside names.

  • e63334287a3deaa3/social.js: Online (Back Room). An Online panel under the Party panel lists everyone who has the Back Room open, by name plus a seat tag worked out from their uid (rows 1–40, seats A–F, like Nico · 14C), so two people with the same name can be told apart (seats can still repeat now and then; there are 240). Each person shows as Idle, in a party (and its size), or in a game. The name starts as the signed-in account's first name, else the saved party or game name, and can be changed in the panel (saved as ol:name). One name everywhere: ol:name is the player's name for the Online panel, the Party panel and the Leaderboards, in one order (ol:name, then the Google account's first name, then party:name, then a game's saved name) in social.js, party.js and stats.js. A rename in the Online box (or the Party panel's name box, which saves both keys) is written to the Online node and, while in a party, to the party node within half a second, so other people see it in about a second; social.js also sets just n on the player's leaderboard entry (rooms/lb-all/players/<uid>/n, only if the entry exists, at most every 4 seconds; once after each page load too, so an entry left with an old name catches up), and fires backroom:name ({uid, n, mine}) on document, as it does when someone else's Online node changes name, so the Leaderboards rename that row at once without a new read. Other tabs follow through the storage event. Measured with two windows: before, a rename reached the other window's Online list in 0.8 s but never reached the Party panel (the party node kept party:name), and the leaderboard entry only changed the next time a game published (up to 60 s with a game open, never without one; the other window needed a Refresh after that); after, Online 0.9 s, Party 0.9 s, Leaderboards 1.2 s live and the entry in about 2 s. Game name boxes (ss:name, shellcr-name …) stay each game's own and don't rename you; party launches fill them with the one name. Hide me (ol:hidden) takes you off the list, and you can't chat while hidden. Joinable games: next to Hide me, Let people join my games (localStorage ol:join, '0' = off; on by default; saved with the account by account-nudge.js; also a "Let people join: On / Off" button in the Online drawer, for use over a game) lets anyone in the Back Room join the online game you're in, party or not. While it's on, you aren't hidden and you aren't in a party set to Private, your Online node carries jg (game key from party.js's GAMES), jr (room code after the game's prefix) and jt (when you got there, server time); they're removed when you leave the room or switch it off, and go stale with the node's heartbeat like everything else there. Single-player games and games not in party.js's GAMES are never advertised. Finding the room: the game runs in its own window (the home page's game layer, the phone page's frame, or party.js's layer on the Back Room page, where party games now open instead of replacing the page) and keeps its connection to itself (every game's Net sits inside the game's own function, so the page can't read it). So social.js lists the rooms the open game could be in: the room in the game's address (#<prefix>-<code>, which every party game sets when it joins a room by code), the room party.js launched it into, the code in its room box, and its Quick match rooms (pub-1 to pub-6; Rumble Karts' lobby to lobby-6). It reads rooms/<prefix><code>/players/<uid>/ts for each (one small read each) and takes the first that heard from you in the last 20 s; the found room is checked again every 4 s while the game is open, the others every 6 s until one matches. Not covered: Backrooms' Quick match (it pairs two players into a 1v1 room with no code), single-player modes, and a game opened in its own tab. Everyone in a joinable game gets a Join button on their row ("In Shell Crackers · Join", or Queue when the room is full and the game plays in rounds), from playingnow.js's checks (window.BackRoomPlaying); it opens the game in that room the same way Playing now does, and closes the drawer if it was open over a game. When someone's node starts advertising the room you're in after you got there, a small toast says "Ben joined your game" for 5 s (not for your own party, who arrive together; not for blocked or muted people). Tap someone to open their card: Request to chat, invite them to your party (one is created first if you're not in one; they get a Join / No thanks card and Join puts them in your party through party.js), or send them a room code (pick a game and a code; while you're in a game it fills in the room you're in). Private messages need a chat request first: the other person gets a toast and a card with Accept / Decline (and Block), and only once they accept can either side message the other; a message from someone who hasn't been accepted (an older client, say) is dropped, and that person's thread just says to request a chat or to accept theirs. A request runs out after 10 minutes if nobody answers, and then the requester can ask again (one open request per pair; at most one request per person every 10 minutes and ten in 10 minutes in all); a declined request shows "Declined" and can be repeated after the same 10 minutes. If both people request each other at once, that counts as a yes. Accepted pairs are kept in this browser (ol:chats, uid to time, at most 60) so you don't have to ask again every visit; End chat in the thread forgets the pair here and tells the other side, which forgets it too. Party invites and room codes aren't chat, so they don't need a request; the cabin chat is unchanged. Parties: people in the same party can message each other straight away, without the chat request (their card says so and has no Request to chat button); "the same party" comes from party.js's own member list (the rooms/pt-<code> nodes, through Party.members), minus anyone on the current leader's kick list (Party.out), never from what someone writes in their Online node, so nobody can claim to be in your party. Outside a party, and once someone leaves or is removed, the request is needed again. Block, session block and mute work exactly as before (a blocked party member can't message you and is hidden). Party chat: a group chat for the party, opened from the Party chat button in the Online panel (shown while in a party), in the Party panel, in the people drawer, or in a game (see gamechat.js). Each message is sealed separately to every member who is in the party at that moment and online here (not to anyone you blocked), with the same ECDH + AES-GCM as private messages, and kept in your own node as pc/<member uid> (the last 6 to each member, { i, t, b }; inside: { f, to, k: 'g', c: party code, x: text }); copies for people who leave the party are dropped, and the oldest copies are trimmed if the node would grow past 50,000 characters. A browser only shows a party message that names its current party code and comes from someone on its current member list, so only the party can read the chat, and people who join later don't see earlier messages; everyone online can see that something was sent to whom and when, as with private messages. Blocked and muted people's party messages are hidden (like the cabin chat), the word filter and the shared rate limit (one message every 1.5 s, five in 10 s) apply, a party message from someone else shows a toast when no game is showing it, and party chat is never logged. Report in someone's private conversation also includes the last 20 party-chat messages (everyone's, marked [party]) when that person wrote in the party chat, and the confirm box says so. The party chat is kept for the tab in sessionStorage ol:pchat ({ c: code, m: [...] }, 80 messages) and starts empty in a new party. window.BackRoomChat (state, recent, send, listen, seen, open) lets the in-game chat inside a game's iframe read and send it through this page; it only hands out messages already checked, filtered and masked here. While a game is open, a Chat button next to Online in the game's top bar (shown while in a party with someone) opens the game's own chat box, or the Party chat drawer for a game without one. Deepword is in GAMES too (status "In Deepword", room codes), so its top bar gets these buttons. Request to join: the card of someone in a party also has Request to join, next to Invite to party. It sends a sealed j to that party's leader: to them if they lead it, otherwise to their leader when that person is online (the card says so and shows how it's going, with a button to open the leader's chat); if a j reaches someone who isn't the leader (their leader is offline, say), their browser answers x with lead and who the leader is, and the requester sees "Only the leader can accept — ask " with an Ask button when that leader is online. A leader with room gets a toast and a card with Let them in / Decline; Let them in sends y with the party code, and the requester gets a Join party button (toast and card) that joins through party.js, so a person who was kicked is still kept out by the party's own 10-minute rule; Decline sends x. A full party (8), someone who isn't the leader, or someone no longer in a party answers x straight away with the reason. One open request per party (per leader) at a time, 10 minutes to answer, a repeat only 10 minutes after a no, and the same ten-requests-in-10-minutes limit as chat requests; a y or x only counts when it answers a request this tab sent. Join-request state lives in the thread in sessionStorage (jq, jx, jw, jc, jl, jln, jin, jnd), like chat requests. Room invites only ever become the site's own invite links (/#<prefix>-<room>, Rumble Karts /#<room>) built from a known game and a checked code, never a link someone typed, and opening one goes straight into that game's room. The cabin chat is one public chat for everyone online; it shows the last 30 minutes from people who are still here. Each cabin message is also saved, sealed, to Firestore chatlog for the admin page (see below). A Privacy link to /privacy.html sits at the bottom of the panel. Mute (ol:mute) hides someone's cabin-chat messages and silences their notifications; Block (ol:block) also hides them from the list and hides their messages, requests and invites; both are saved only in this browser. Block for this session does the same as Block but is kept in sessionStorage (ol:sblock), so it's forgotten when the tab closes; the Blocked section of the people view lists both, with "(this session)" on the latter, and Unblock clears either. While a game is open in the page's game layer (home page, phone page, or a party launch), an Online button in the game's top bar opens the same list, chats and invites, and the status shows "In "; a game opened by its card on the Back Room page opened directly leaves the page, so that person goes offline until they come back. Loaded like party.js: a <script> tag on the Back Room page, by the home page's reveal() and by m/index.html. It signs in through party.js (window.Party, a small hook at the end of party.js that also lets it read the party code and size, create or join a party and open a game room). Storage: Firebase Realtime Database, same project and rules as Rumble Karts, one shared room, rooms/ol-lobby/players/<uid> (prefix ol, not an invite prefix). The rules only let a browser write its own node and need n, c, x, z, h, ts (x/z/h always 0), so everything is in that node: v (format 1), pk (the browser's ECDH P-256 public key), p (party size), pl (the party leader's uid while in a party, so a join request can go to the leader; older clients leave it out and ignore it), g (game key), pp, pg, pr, pn, pt (a public party's code, game, room, size and launch time, leader only, for Playing now), l (cabin chat: the last 20 messages, 200 characters each, dropped after 30 minutes), d/<recipient uid> (the last 6 private items to each of the last 6 people: messages, kind m, the chat-request handshake, kinds q request, a accept, n decline and e end chat, and the join-request handshake, kinds j request, y yes with the party code c, and x no with why in w: no, full, lead (only the leader can accept; l and ln are the leader's uid and name) or gone, all in the same sealed { i, t, b } shape) and i/<recipient uid> (the latest party or room invite to each of the last 6 people, dropped after 10 minutes). Private messages, requests and invites are sealed like Showdown's hole cards: AES-GCM with a key from ECDH between the sender's private key and the recipient's pk, so only those two browsers can read them. A browser only acts on an a, n or e that answers something it did itself (a request it sent within the last 10 minutes, a chat it has accepted), so a stray "yes" from someone you never asked opens nothing; unknown kinds are ignored. Request state (asked, declined, asked by them) lives with the conversation in sessionStorage, so a request you sent is forgotten when the tab closes (ask again), while accepted pairs are in localStorage ol:chats. Everyone else can see that a message or invite exists, who it's to and when, but not what it says. The cabin chat, names and statuses are public to everyone online. Safety log: when a cabin message is sent, social.js also writes chatlog/<random id> = {kind: 'cabin', uid, ts (server time), box}, with the name, seat, text and send time sealed inside box to the admin key (adminkey.js), so only the admin page with both admin codes can read it. Private messages and chat requests are never logged. Instead, a Report button in each private conversation saves that conversation as the reporter's browser shows it (the last 20 messages, invites and request steps, decrypted on the reporter's side, at most 4,000 characters), after a confirm box that says exactly what is sent: {kind: 'report', uid (the reporter), ts, box} with the reporter's name and seat, the other person's uid, name and seat, and the transcript inside box; one report a minute per person. Each browser also keeps a plain who/<uid> document (uid, vid = the analytics visitor ID from localStorage an_vid if there is one, name, seat, ts), written when it goes online and when the name changes (not while hidden), so the admin stats can put a name to a visitor. All three go through the Firestore REST API with the account's ID token, like feedback.js; until the rules from the admin page entry are added, Firestore refuses them, and that is silent and never holds up the chat (the report says reports aren't switched on yet). The key pair is kept (non-exportable) in the browser's IndexedDB (backroom-online), so it survives reloads; conversations are kept for the tab in sessionStorage (ol:threads) and are gone when the tab closes. Closing the tab removes your node (onDisconnect), so your cabin-chat messages (apart from the sealed copy in chatlog) and anything you sent that the other person hasn't opened go with it; nodes without a heartbeat for 75 seconds are ignored. Everything other people write is checked before it's shown: text is never parsed as HTML, numbers are clamped (times can't be more than a few seconds ahead), oversized or malformed nodes are skipped, a short word filter blanks a few rude words (the list is in ROT13 in the file), and sending is limited to one message every 1.5 seconds and five in ten seconds. These are client-side protections only; the shared rules don't limit node size, so someone with developer tools could still write junk into their own node (which other browsers then skip). Every browser downloads every node, so it's meant for tens of people at once, not thousands. Two tabs signed in as the same person share one node and can overwrite each other's chat. Names come with an avatar (avatar.js, drawn from the uid) and a status tier badge (miles.js): in the list and cards (the badge on its own line under the name, so rows are a little taller), the cabin chat, the thread headers, the Blocked list and toasts; your own "You" row and your card in the drawer also say your miles and how far to the next tier ("2,500 miles · 7,500 to First Class"). The tier of each person online comes from their leaderboard node, read once per uid through miles.js and kept for 10 minutes; without the two scripts, a coloured initial and no badge.

  • e63334287a3deaa3/playingnow.js: Playing now (Back Room). A panel under the Party panel (#playingnow) that lists the online games anyone can join right now: every public party's game, and the game of every player who lets people join their games (on by default, see social.js), grouped by game and room, one row each: the game's thumbnail (m/thumbs/<folder>.jpg) and name ("Shell Crackers", or "Shell Crackers · party of 6" when a public party is there), up to three avatars, who's there ("Room with Ava, Ben and 3 others", or "Led by Ava · with Ben and 2 others" for a party; only people who advertise the room are named, everyone else in it is a number), how long since the first of them got there, how many are in the room out of its limit, and a button. Join opens the same game in the same room through party.js's own launch (Party.launch: the name and room are filled in and the game's Play in room button is pressed), without making you a party member. When the room is full, games played in rounds (Shell Crackers, Rumble Karts, Slipstream, Boostball, Coil, Showdown, Backrooms, Chromaflage, Spire, Turbo Trail, Escapade, Updraft, Odd One Out, Tumble Trials) offer Queue for next game: the tab checks the room every 3 s and launches you as soon as there's a spot (after a short random wait and a second look, so two people queued for one spot don't both rush in; one queue at a time, stops after 30 minutes or when that game ends or nobody there lets people join any more, or you open a game). Deepword and Puffer Party show Full instead. Room limits are each game's own (its join check), in CAP at the top of the file: Shell Crackers 8, Rumble Karts 12, Slipstream 22, Boostball 2 × the room's team size (read from its round.sz, else 6), Coil and Showdown 10, Backrooms and Chromaflage 8, Spire, Turbo Trail, Escapade, Updraft, Deepword and Puffer Party 12, Odd One Out and Tumble Trials 16; a player counts if their node in rooms/<prefix>-<room>/players was heard from within the game's own live window (8–12 s). A game added to party.js's GAMES later without a CAP row uses party.js's max, a 10 s window and no queue. Public parties: the Party panel's leader view has a Public / Private switch. Parties are Public by default; the leader's choice is remembered in the browser (localStorage party:pub, '1' or '0', saved with the account by account-nudge.js), so a leader who switched to Private keeps getting Private parties. While the leader's party is Public it writes pub: 1 and pn (party size) to the leader's own party node, and members see "This party is public". Only the leader's setting counts: a new leader's own choice takes over. Private ("only your party can join its games") also stops every member's own game from being advertised (Party.priv). How the list is built (no rules change): the rules don't let a browser list rooms/, so it can't look through every rooms/pt-<code> or game room. Instead everything is advertised on each player's own Online node rooms/ol-lobby/players/<uid> (social.js; the same listener data the Online panel already downloads), and checked before it's shown. A public party: while the leader's party is public and the game it launched the party into is open (Party.pub in party.js), the leader's node has pp (party code), pg (game key), pr (room), pn (size) and pt (launch time); playingnow.js checks it against the party's own nodes (rooms/pt-<pp>/players, read at most every 15 s): the advertising node must be the party's leader (earliest j among nodes with a heartbeat in the last 2 minutes, party.js's rule) with pub: 1 and go naming that game and room. A player's own game: jg (game key), jr (room code after the game's prefix, e.g. ab12 or pub-3) and jt (when they got there); playingnow.js checks it against the game's own room (rooms/<prefix><jr>/players, the same read as the player count): that player's uid must be there, heard from within the game's live window. Private parties, games not in party.js's GAMES, your own party, Online nodes over 75 s (players) or 2 minutes (parties) without a heartbeat, and anyone with Hide me on (no Online node) or "Let people join my games" off (no jg/jr) are never listed. The room counts and checks are read every 5 s, only while the page is visible and no game is open (or while the Online list is open over a game, for its Join buttons). For the Online panel: window.BackRoomPlaying gives social.js a Join button for each person's row: state(uid) ({ label, game, pri, off }, or null when they aren't in a listed room) and act(uid) (join, or start or leave the queue); the file fires backroom:playing on document whenever the list or a button changes. This file writes nothing.

  • e63334287a3deaa3/avatar.js: avatars (Back Room). A small cartoon face for every player, drawn as inline SVG from their uid, so one person looks the same in the Online panel, the Party panel, the Leaderboards and the admin page, and distinct from the people around them. Loaded on the Back Room page and the phone page (one <script defer> in each page's <head>), by the home page's reveal() and by the admin page. window.BackRoomAvatar = {seed(uid), svg(uid, size, opts), el(uid, size), FEATURES, PALETTE}: seed gives the features a uid gets (a background from a 12-colour palette, 6 skin tones, 8 hair styles and 9 hair colours, 6 eyes, 5 brows, 6 mouths, glasses, freckles, blush and 5 hats, each an index), svg the drawing as a string and el the same inside a <span class="bra-av">, at any size from 20 to 96 px. The features come from the same hash as the seat tag in social.js (hash('seat:' + uid)) through a small seeded generator, so nothing is stored anywhere and the markup is built only from numbers (never from a name or anything else someone typed). There is no editor yet, but the hook for one exists: seed() reads localStorage avatar:v1 = {v: 1, uid: '<the account it is for>', <feature>: <index>, …} and uses every valid field when uid matches; a later editor should write that key (and publish the choice somewhere, because other browsers can't see this one's localStorage). The scripts that show names (social.js, party.js, leaderboard.js, the admin page) call BackRoomAvatar.el when it is there and fall back to a coloured initial when it isn't; when the file loads it fires backroom:avatar on document, so panels drawn before it arrived redraw (the home page loads it alongside the others, in no fixed order).

  • e63334287a3deaa3/cosmetics.js, wallet.js and shop/: skins, accessories, skin draws, packs and the coins to buy them with (Back Room). Switched off: var ENABLED = false; at the top of cosmetics.js hides every part of it (no Shop chip, no wallet, no draws, no skin picker, nothing saved), and shop/ then only says the shop isn't open yet. ?cosmetics=1 turns it on for testing, on localhost or in an automated browser only. Hub pages load both scripts (cosmetics.js with data-hub, which adds an "emeralds · Shop" chip to the .topbar or to an element marked data-cosmetics-slot when switched on).

    • Coins. Mangos are the small coin and emeralds the big one: 13 mangos make an emerald and 7,321 mangos make a dollar, so $1 is about 563.15 emeralds. Every sum is in whole mangos and every price is in whole emeralds. BackRoomWallet.{balance(), inEmeralds(), format(), earn(mangos, why), spend(emeralds, why), log()} fires backroom:wallet on document when the balance changes.
    • Earning. Play pays mangos through stats.js's existing hooks. wallet.js wraps BackRoomStats.add/max/min in the page and in a hub page's game frames, and pays from EARN: mangos per unit for counts, per call for bests, and a daily cap per game by the browser's date. Every change is logged in wallet:log (the last 200). There's a one-time 20-emerald welcome gift when the shop first opens.
    • Catalogue. SKINS has 30 skins: 12 common, 9 rare, 6 epic and 3 legendary. Each has an avatar look and a 3D colour set colors {body, accent, trim, emissive, pattern}, and texture(id, size) paints the pattern on a canvas. ACCESSORIES has 23, one per slot hat, glasses, cape, trail, aura, pet worn with any skin; each has a three description (attach, shape, color, color2, size) for 3D games. All of them are drawn in code. Item no numbers are permanent.
    • Draws. Odds are weights out of 10,000: common 7,000, rare 2,200, epic 700, legendary 100 (exactly 1%). They are picked with an unbiased whole-number roll: crypto.getRandomValues in play, seeded(n) in tests. A duplicate gives back 52 mangos (40% of a 10-emerald draw), and epic and legendary skins only come from draws.
    • Packs. PACKS holds everything buyable, with whole-emerald prices: draw packs (1, 5 and 10 draws), themed skin packs, accessory packs, and emerald packs (dollars; refused until payments exist). buy(id) checks the wallet, takes the emeralds, fills the locker and refunds duplicates; a fully owned pack is refused.
    • For games. BackRoomCosmetics.equipped() returns {skin, accessories: {slot: id}, colors}, and avatarSvg(uid, size) returns the avatar wearing them.
    • Storage. Everything is in this browser: wallet:v1 (base balance at last sync, pend change since, su synced uid, dc today's earnings per game, dv browser id), wallet:dev, wallet:log and cosmetics:v1 (s, a owned ids, e worn per slot). Signed in with Google, the wallet is mirrored into the player's own stats node rooms/lb-all/players/<uid> as w {v, b (mangos), s, a (owned bitmasks by item number), e ('s12,h3'), lh (log hash), t}. This is a transaction on w that adds this browser's pend to the node's balance, so devices add up. The mirror waits until the node exists, and it rewrites w when a stats.js publish drops it.
    • Not cheat-proof. Like every other stat, the balance is self-reported: a player can edit their own storage and node. That is fine for free cosmetics, but real money needs a server-held ledger and rule changes. BackRoomWallet.PAYMENTS ({provider: 'stripe', enabled: false, checkout()}) documents what that takes, using the existing store.js and functions/ setup, and today only answers "not available".
  • e63334287a3deaa3/miles.js: status tiers, "miles with us" (Back Room). Every player is Economy, Business Class, First Class or Private Jet, except the site's two owners, who are always Captain (see below), shown as a small pill next to their name in the Online panel, the Party panel, the Leaderboards and the admin page (loaded in the same four places as avatar.js). window.BackRoomMiles = {of(node, uid, now), tierOf(miles), isOwner(uid), badge(result, {miles}), line(result), peek(uid), load(uid), fmt, TIERS, RULES, CAPTAIN, OWNERS}. of takes a leaderboard node (rooms/lb-all/players/<uid>, raw or already parsed by leaderboard.js), checks it with BackRoomBoards.parse (the same caps and ranges the boards use, so a stale, oversized or impossible node is simply Economy with ok: false, and nothing it contains is ever shown as HTML) and gives {miles, tier, next: {tier, miles} or null at the top, hours, play, bonus, ok}. The formula: 100 miles per hour played (the hours the boards accept: at most 24 a day since 21 October 2026, all games together) plus points for success in each game, each kind capped so no single game carries someone (the table is RULES in the file): running counts, wins 40 each in Rumble Karts, Backrooms and Boostball (cap 4,000), kills 2 in Shell Crackers and Backrooms (2,000), wrecks 2 (2,000), goals 10 (2,000), Coil cut-offs 5 (2,000), Chromaflage hiders found 10 (2,000) and rounds hidden 20 (2,000), Odd One Out props found 5 (2,000) and rounds won 20 (2,000), Tumble Trials crowns 50 (4,000) and shows 5 (1,000), Spire towers 20 (2,000), Trivium streak 30 a day (1,500), Showdown hands 2 (1,000), Gridpop lines 2 (1,000), Stackfall lines 5 (1,000), watermelons 50 (1,500), Puffer Party online wins 20 (2,000) and answers 1 (1,000), daily puzzles finished (the Daily Puzzles page's done) 5 (2,000) and its best daily streak 10 a day (1,000); personal bests, Elo above 1,200 at 4 a point (3,000 at 1,950 Elo), fastest Spire tower under 10 minutes at 3 a second (1,500 at 100 s), fastest obby under an hour at 0.4 a second (1,200 at 10 min), fastest Updraft summit under an hour at 0.5 a second (1,500 at 10 min), Updraft height ÷ 2 (500 at the summit), a Slipstream lap within 2.5× the track's quickest possible lap up to 200 a track (2,000 for all tracks) and 100 a course record held (1,000), a 2048 tile of 2048 (500), 4096 (1,000) or 8192 and up (1,500) and best score ÷ 100 (500), Gridpop score ÷ 50 (1,000), Stackfall Classic and Sand scores ÷ 200 (1,000 each), Melon Merge score ÷ 10 (1,500), Coil's longest snake ÷ 10 (1,500), Trivium's best Daily ÷ 10 (1,500) and Showdown's biggest pot ÷ 200 (500). Tiers: Economy under 2,000, Business Class 2,000–9,999, First Class 10,000–39,999, Private Jet 40,000 and up. So an hour a day for three weeks is Business Class; First Class takes about 100 hours, or 50 with a lot of winning (a 1,950 Elo, 100 kart wins and an 8192 tile are 8,500 on their own); Private Jet is a year of play. Captain: a tier above Private Jet for the site's owners, Nico and Arjun, listed by account uid in OWNERS near the top of miles.js (Nico's two accounts and Arjun's three, each with a comment saying whose it is; add or delete a line to change it, and never match on names, since anyone can type any name). An owner's of result has tier Captain, next: null, owner: true and real, the tier their miles alone would earn; their miles are worked out exactly like everyone else's, so the admin page's Miles column shows and sorts by their real miles (with the Captain badge beside it), and nobody else's tier or the maths changes. The badge is pilot's wings in gold on a near-black navy pill with a gold rim (.bra-tier-captain), and on an owner's own Online card the progress line is "12,345 miles · flies the plane" instead of "N to " (line(result)). This is a display rule, not a security feature: tiers are worked out in each viewer's browser from public data, so it only decides how pages draw the badge and grants nothing. The score rules were tuned against the live boards a week after they began (85 accounts: 77 Economy, 7 Business, 1 First under the first draft, several Business with under an hour of play because one good Stackfall or Coil sitting hit a cap), so a single sitting of a puzzle game is now worth a few hundred miles at most. Worked examples: 10 h of Coil + 100 cut-offs + an 8,000-long snake = 1,000 + 500 + 800 = 2,300 (Business); 50 h of Shell Crackers + Elo 1,950 + 3,000 kills = 5,000 + 3,000 (the cap) + 2,000 (the cap) = 10,000 (First Class exactly). For people online, peek(uid) gives the cached result (and starts a read when there is none or it is over 10 minutes old) and load(uid) a promise of it: one read of rooms/lb-all/players/<uid> per uid through party.js's sign-in; a finished read fires backroom:miles on document (detail.uid), and so does the file when it loads. The Leaderboards and the admin page already hold every parsed node, so they call of directly. Everything behind a tier is self-reported by the player's own browser (see stats.js), so tiers are for fun, like the boards; nothing here writes to the database, and stats.js's node format is unchanged. On the Leaderboards (leaderboard.js), rows show the player's avatar and tier badge (the tier worked out from the same checked node, so a node the boards would skip can't earn one) on a second line under the name, and the first board, Miles, ranks everyone by these miles (All time, the same number as the badge) or by miles from this week's play time (This week: HOUR, exported as BackRoomMiles.HOUR, per hour in the last 7 days; bests and counts have no date, so they stay in All time). The badge always shows the all-time tier, on the weekly board too.

  • e63334287a3deaa3/keybinds.js: rebindable controls shared by the keyboard Back Room games (2048, Backrooms 1v1–4v4, Boostball, Coil, Escapade, Gridpop, Hail Mary, Kartwheel, Melon Merge, Odd One Out, Quarry, Rumble Karts, Shell Crackers, Slipstream, Spire, Stackfall, Starhop, Tumble Trials, Turbo Trail, Updraft, Driftfall, Asteroid Run, Rampline, Tidewick). Plain JavaScript, no dependencies. Each game loads it with <script src="../keybinds.js"></script>, lists its actions in Keybinds.create({game, actions: [{id, label, keys: ['KeyW', 'ArrowUp']}]}) (keys are e.code names, so they're physical keys on any layout; mouse buttons are Mouse0/Mouse1/Mouse2 in games that pass mouse: true), checks input with KB.is(action, e.code) and KB.held(action, keys) instead of comparing key codes, and opens the panel from a Controls button with KB.open(). Changed keys are saved only in the player's browser, under localStorage keybinds:<game> as {v: 1, b: {action: [codes]}} (only the actions that differ from the defaults; Reset to defaults removes the entry), and never go to Firebase. Esc is never rebindable. Elements with data-kb="action" show the current keys and update themselves. The panel takes each game's look from --kb-* CSS variables and the game's own button classes; the comment at the top of the file lists the options. To add it to a game, do the same, and keep text inputs (names, room codes, chat) returning before any key lookup. Touch controls don't go through key codes: each game's touch buttons feed its input directly (in Shell Crackers, Quarry and Backrooms they hold actions by id in tAct, which the game checks alongside KB.held), so they keep working whatever keys are bound, and the Change keys / Controls buttons are hidden in the touch layout (body.touch).

  • e63334287a3deaa3/news.js: What's new (Back Room). A slim strip under the page header, above the Party panel, on the Back Room page and the phone page (one <script defer> in each page's <head>; the home page's reveal() also loads it). It shows the latest entry's kind (an emoji: a game controller for a new game, sparkles for an update, a wrench for a fix), version and title and when it went live (Today, Yesterday or the date), and, for a week after an entry with new games (NEW_DAYS), a "New games!" row with each new game as a colourful chip that opens it (in the home page's or phone page's game layer through window.BackRoom.openGame when there is one, otherwise as a plain link to the game's folder). Tapping the strip opens the changes as a card per release (kind badge, title, the new games' chips, bullets), grouped by day, five days at a time, with Show older for the rest. Its colours come from the page's CSS variables, so it suits the light Back Room page and the dark phone page. A dot on the strip marks entries this browser hasn't opened yet (localStorage news:seen, the latest entry's date and version once the list has been opened); nothing is stored online. The changelog is the NEWS array at the top of the file, newest first. When your pull request changes something players will notice, add an entry at the top of NEWS: {date: 'YYYY-MM-DD', version: '1.7', title: 'A few words', kind: 'game', games: ['Deepword'], items: ['One plain sentence per change', …]}, with date the day it went live, kind game (a new game, shown as "New game 1.7"), feature (a new feature or big update, "Update 1.7") or fix ("Bug fix 1.7.1"), and games (optional) the names of brand-new games in the entry, as on their cards; each opens the Back Room folder named like the game in lowercase with dashes ('Melon Merge' → melon-merge/), or write 'Name:folder' when the folder is named differently. Leave games out for updates and fixes. Two optional fields make entries easier to notice: img, a snapshot or a list of up to three, as a plain path inside the Back Room folder (use the game's phone thumbnail, 'm/thumbs/<folder>.jpg'; anything else, such as http: or ../, is ignored, so every picture comes from the site), and tags, up to four short keywords (['Battle royale', 'Up to 30 players', 'Build'], 28 characters each at most). The latest entry's first snapshot sits next to its headline on the strip, the open list shows each card's snapshots and tags, and the "New games!" chips get a tiny picture each by themselves (the game's m/thumbs/<folder>.jpg as a background, so a game without one keeps its plain colour). Spotlight: SPOTLIGHT (under NEWS) pins one game at the very top of the strip for days days from from (now: "New battle royale" Driftfall, 21 days from 9 November 2026): its snapshot, a kicker, its name, two or three keyword tags and a Play button that opens it like the chips do; while it shows, that game is left out of the "New games!" row. It sits under the page header on the Back Room page, the home page's copy and the phone page, so it's the first thing under the title, and on phones (560 px wide or less, or any screen under 500 px tall, so a phone sideways) it's one compact row: thumbnail, kicker and name, Play and ×. The × hides it in this browser (localStorage news:spot = the spotlight's from|folder); the record is removed once that spotlight has ended or a different one is set, so a new spotlight shows to everyone again. Set SPOTLIGHT to null for none. window.BackRoomNews also lists each entry's img and tags and the current spotlight. A new game or big feature bumps the minor number (1.6 → 1.7); a fix-only release adds or bumps a patch number (1.7 → 1.7.1 → 1.7.2). Several entries can share a day. Write the items for players, in your own words, and leave out anything private or admin-only. The text is shown as plain text, never as HTML. The history before the strip existed (1.0 on 13 October 2026 to 1.6) was written from the merged pull requests, one entry per day. Gridpop, 2048, Stackfall and Melon Merge went live on 23 October without an entry, so theirs ("Four puzzle games") was added afterwards with no version number, which is allowed for an entry like that (it shows as just "New game").

  • e63334287a3deaa3/layout.js: Edit layout (Back Room). Each player can put the page's big blocks in their own order, and choose which games have big cards. It's loaded with one <script defer> in the <head> of the Back Room page and the phone page, and the home page's reveal() loads it too. A small "Edit layout" button sits at the top of the games: on the Back Room page it's in the Games section's top padding, and on the phone page it's in a slim row above the first heading. It opens an editor where every movable block is a bar (its icon or thumbnail, name and a short line) with a drag handle and ↑/↓ buttons. With a mouse you can drag anywhere on a bar. On touch you drag by the handle, so the list still scrolls, and it scrolls by itself near the edges. The arrow keys also work on the handle, and a line shows where the bar will land. The blocks themselves are hidden while editing (html.lay-editing). Done saves the order and redraws the page. Reset puts the default order back and saves that. The editor has two tabs, Order (the bars) and Game sizes. Game sizes shows each section's games as tiles (thumbnail, name and a Big/Small label) in the section's own number of columns at the current screen width, so it's a live preview of how the rows will come out. Tapping a tile switches it between big and small (switching a game back to the size the site gives it forgets the choice), and each section has All small, All big and Default (back to the site's sizes); Reset on this tab puts every game back. On the Back Room page (and the home page's copy) big means wide + pw (two columns on computers, a whole row on phones) and small means neither; a game the player hasn't chosen keeps the classes the HTML gives it, so a new game still gets the size Arjun gives it. On the phone page big is .lay-big (a whole row upright, two of three columns sideways with the picture on the left), and every tile is small by default. The Daily Puzzles card and row aren't sized. A game's key is its folder, read from the card's links (or the tile's thumbs/<folder>.jpg), so every card, including new ones, is picked up by itself, and a game on both pages shares one entry. Gaps: a big card that doesn't fit in what's left of a row starts the next row and leaves a gap, so in a section where the player has chosen any size, the card before a gap stretches to fill the row (.lay-fill, an inline grid-column: span N, plus .lay-fw for the wide card's look). The player's choice isn't changed; the editor shows which card stretches with a one-line note under the section, and it's worked out again when the number of columns changes. Sections the player hasn't touched are left exactly as they are. Card widths come from the page's own CSS (each card's computed grid-column), so the preview and gap handling follow games.css and the phone page at every width. The blocks on the Back Room page (and the home page's copy) are party, online, daily (the Daily Puzzles card), one per genre section (puzzles, parkour, action, multi, solo, taken from each gs-<name> class, so a new section is included by itself) and leaderboards. On the phone page they are party, online, every GROUPS row (m- + the row's name in lowercase with dashes, and daily for the Daily Puzzles row) and leaderboards. Each run of rows of one mode gets that mode's heading, with the count for that run. The order is kept per browser in localStorage layout:v1 = {v: 1, order: [ids], size: {<game folder>: 'big'|'small'}}, and it is also in account-nudge.js's synced keys, so the sizes sync with it. size is optional (older records without it still work, so no version bump) and only names the games the player chose; with only sizes saved, order is []. Saving from one page keeps the sizes of games that are only on the other page. Both pages share one list: each page uses the ids it has, and when it saves, it keeps the other page's ids next to the block they followed. A block the list doesn't name (a new section, or a panel that hadn't loaded yet when the order was saved) goes to its default place. With no saved order (or the default order) and no sizes, the page isn't touched apart from the button, and the key is removed. How a different order is applied: on the Back Room page, the Party, Online and Leaderboards panels move inside section.br-games (.lay-on), because the genre sections need its styles. The blocks go there in order, with the "Want more games?" note after the last games block. A Daily Puzzles card that isn't directly above Puzzles gets its own <section class="gs gs-puzzles lay-daily">, and Puzzles' count drops "+ daily". On the phone page, the rows and panels go into .wrap before #lists. party.js, social.js and leaderboard.js add their panels after layout.js runs, and social.js moves Online back under Party once. A MutationObserver on the wrap and the Games section (or #lists) puts any block that lands out of place back in order before the next paint. It only moves elements that are out of place, so it doesn't loop with news.js's own observer. The home page's reveal() copy is the default HTML, so layout.js rearranges it there in the same way. Nothing is stored online by this file.

  • e63334287a3deaa3/netsmooth.js: smooth movement for other players in the online games. Updates over school Wi-Fi and Firebase arrive late and in bursts, so a game that moves someone to their newest update makes them lurch and stall, and one that shows them a fixed time in the past freezes them whenever an update is later than that. Each remote player gets a NetSmooth.lag(), which measures how late their updates really arrive and shows them just far enough in the past that the next one is almost always already here (about a tenth of a second on good Wi-Fi, more on bad, never more than 0.45 s), easing that delay so the timeline never jumps; NetSmooth.sample() draws them between the two updates around that moment and carries them on for up to 0.3 s if an update is late. Used by Shell Crackers, Backrooms 1v1–4v4, Hail Mary, Coil, Quarry, Chromaflage, Odd One Out, Spire, Turbo Trail, Escapade, Updraft and Driftfall. Kartwheel, Slipstream, Boostball, Rumble Karts and Tumble Trials keep their own approach: cars, karts and racers are projected forward to the present from their speed, which matters more there than perfectly even motion. Settings were tuned with a simulation of good, school and bad Wi-Fi: compared with chasing the newest update, other players stall about a fifth as often and almost never lurch, for roughly a tenth of a second more delay.

  • e63334287a3deaa3/autores.js: automatic resolution for the 3D games, so they stay smooth on school laptops. Every three.js game except Quarry (which has its own version) loads it right after three.min.js. It watches the frame rate, and when frames drop below about 45 a second it makes every three.js renderer on the page draw at a lower resolution, a tenth at a time, down to half; when frames stay smooth it steps back up (more slowly towards a resolution that was too slow before, so it doesn't keep flipping). It multiplies whatever setPixelRatio the game asks for, so a game's own graphics setting still counts. The scale each game settles on is remembered in the browser (localStorage key autores:<page path>), so the next visit starts there. No game code calls it; it wraps THREE.WebGLRenderer. AutoRes.scale shows the current scale and AutoRes.off() turns it off for a visit.

  • e63334287a3deaa3/account-nudge.js: the sign-in nudge and account save (Back Room). Loaded on the Back Room page and the phone page (one <script defer> in each page's <head>; the home page's reveal() also loads it), never inside a game. The nudge: when the page opens and the site's Google sign-in (account/account.js, window.SiteAccount) reports nobody signed in, a small card under the What's new strip and above the Party panel says "Sign in with Google to save your skins, records and settings across devices", with Sign in (the normal Google popup, SiteAccount.signIn()) and Not now, which hides it for 7 days (localStorage sv:nudge, the time it was dismissed). It never signs anyone in by itself, isn't shown while signed in, and shows nothing on a page without the sign-in: the phone page opened on its own has none (no top bar, so account.js isn't there), but inside the home page's phone frame it uses the home page's sign-in. The prompt: once per visit (sessionStorage sv:prompt, shared by the page and its phone frame), the first time a hub page (the Back Room page, the home page after its reveal, or the phone page inside the home page's frame) finds nobody signed in with Google, a centred card over the page asks them to "Sign in with Google to save your account, skins and records", with Sign in with Google (the same SiteAccount.signIn() popup) and Not now. It never blocks the page: Not now, Esc, the × button or a click/tap outside close it at once, nothing is signed in automatically, and afterwards the small card is there as before (with its own 7-day quiet period). It waits while a game layer is open over the page (an invite link) and closes itself if a game opens; on the Back Room page it first waits (at most 4 s) for the prize wheel (account/wheel.js), whose offer also asks signed-out visitors to sign in, and if that offer opens it counts as this visit's prompt, so there are never two popups. Nothing is shown while signed in, or on a page without SiteAccount (the phone page opened on its own). The test hook __acctNudgeT.modal says whether it's open. The save: while someone is signed in with Google, a whitelist of the games' localStorage keys is mirrored to the Firebase Realtime Database (same project and rules as the games) at rooms/sv-saves/players/<uid> (prefix sv, not an invite prefix), so they come back on another device after signing in there. The whitelist (KEYS and PREFIXES in the file): names (party:name, ol:name and each game's name key), the Online panel's block and mute lists (ol:block, ol:mute), its Let people join my games setting (ol:join) and the party Public / Private choice (party:pub), skins, colours and looks (Coil skin; Shell Crackers colour and loadout; Boostball body and accent; Chromaflage colour, shape, size, roughness, recent colours and paint; Spire, Updraft and Odd One Out colour; Tumble Trials look; Showdown avatar; Rumble Karts colour), bests and records (coil:best, shellcr-rank, spire:profile, updraft:best, tumble-stats, tumble-best-*, sd:pstats, trivium.streak, trivium.best.*, trivium.daily.*, g48:best, gridpop:best, stackfall:best:*, melon-merge:best), settings (difficulty, graphics, sensitivity, invert, volume, music, mute, view, zoom, camera, laps, track, practice map, host settings, Showdown prefs, Trivium quick-play options, 2048 options, Stackfall sand colours, Melon Merge faces, Gridpop help seen) and keybinds (keybinds:*). Left out on purpose: room codes, in-progress game saves (g48:game, gridpop:save, updraft:run), stats.js's lbs:* keys (it syncs its own record to rooms/lb-all), the Online panel's key pair (IndexedDB) and seen lists, admin codes, feedback drafts, analytics ids, and this script's own sv:* keys. Everything in the node can be read by anyone signed in (the shared rules let any signed-in browser read any room), so nothing private goes in: every key in the whitelist is already visible to other players in-game or on the leaderboards. Only the owner can write their node. Node format: n (name, 14 chars), c (colour from the uid), x/z/h (0, required by the rules), ts, v (1), d ({key: value}, the raw localStorage strings; ., #, $, /, [, ] and % in a key are written as %XX, because Firebase keys can't hold them) and t ({key: ms}, when each key last changed; a key in t but not in d was deleted then). Values over 64 KB are skipped and the node is kept under about 200 KB (largest keys dropped first). Merging: on sign-in, and on load when already signed in, the node is read once and for each key the newer copy wins (ties keep the local one); a key this browser changed while the script wasn't running (a game opened on its own) is noticed by a hash of each value kept at the last sync (localStorage sv:meta, {u, t, h}) and stamped with the current time; a key this browser has never synced for this account has no time, so the account's copy wins over it (a new device's random defaults can't wipe the account's skins and records), and a key the account lacks is uploaded. Afterwards changes are watched (storage events from other tabs and from games running in this page's frames, a poll every 15 s, and pagehide) and written a few seconds later, only when something changed. Nodes are read defensively (unknown keys, non-strings, oversized values and bad times are ignored). Firebase comes from party.js (window.Party.boot()), and it only writes when that account is the Google account SiteAccount shows; it never signs anyone in and doesn't change how the games pick their account. The leaderboard link: while the page is signed in anonymously it keeps that anonymous user (party.js's), and when a Google sign-in arrives on the page it loads stats.js with data-lib and calls BackRoomStatsLib.link(), which marks the anonymous Leaderboards entry as merged into the Google one and has the Google entry take its hours and bests (README: stats.js). Nothing happens for an account already signed in when the page opened, or when the anonymous account has no entry. A small "Saved to your account · just now" line sits in the card's place while signed in, and a "Restored your skins and records from your account" toast shows once after a restore that changed something. Limitation: a game reads its saves when it opens, so a value restored from the account appears the next time that game is opened (the hub pages restore on load, normally long before a game is tapped), and two devices open at the same time only see each other's changes on their next load or sign-in. A test hook (window.__acctNudgeT) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/seasons.js: seasonal looks. Loaded by the Back Room page, the phone page and the Daily Puzzles page (not by games), and by the home page's reveal() only once the Back Room is open there, so the home page itself never gets the seasonal look. The viewer's own date decides the season: fall from September 22 to November 26 tints the page background warm and lets a few leaves drift down; winter from November 27 to the end of February tints it light blue, drops snow and slowly builds a thin snow line along the bottom edge over the first minute. The rest of the year it does nothing (FALL_FROM/FALL_TO at the top of the file). The tint only overrides each page's colour variables (--ground, --panel-2, --line, --panel from site.css, --bg on the phone page, --night on the Daily page), mixed a little towards a warm or cool colour; text colours are left alone. The shapes are drawn on one fixed canvas, #season-fx, inserted first in <body> at z-index -1 with body{isolation:isolate}, so it sits above the page background and under everything else; it takes no space, ignores the pointer, pauses when the tab is hidden or a game layer covers the page, runs at 30 fps on phones, and with prefers-reduced-motion shows the tint and a few still shapes only. A small 🍂 / ❄ button in the footer turns it off or on for that browser (localStorage season:off). For testing: ?season=fall|winter|none in the address, or BackRoomSeasons.set('winter') in the console (set() alone goes back to the date); BackRoomSeasons.seasonFor(date) is the date rule and BackRoomSeasons.bench(n) returns milliseconds per frame.

  • e63334287a3deaa3/feedback.js: the feedback box (Back Room). A slim "Got an idea or found a bug? Tell Arjun" bar at the very top of the Back Room page and the phone page (one <script defer> in each page's <head>; the home page's reveal() also loads it next to party.js, because the home page doesn't run the Back Room page's <head>). Any element with data-feedback-open (the Tell Arjun buttons under the games on the Back Room and phone pages) scrolls to the bar and opens it, as does window.BackRoomFeedback.open(). It opens into a small form: kind (Idea or change, Bug, Something else), which game, the message (up to 1,000 characters, with a counter) and an optional name (40) and way to reply (80). Storage: Firestore feedback (same rumblekarts project as the games), one document per note: uid (the sender's Firebase account; the rules require it to match the account that wrote it), ts (the server's time), game (a Back Room game folder name or other), kind (idea, bug or other) and box, which holds the message, name, reply field and page sealed to the admin key (see adminkey.js below), so only someone with both admin codes can read them; game and kind stay plain so the admin page can filter. Notes sent before sealing was added are plain (msg, game, kind, name, contact, page, uid, ts) and still show on the admin page, marked "Not sealed". It writes through the Firestore REST API with the browser's Firebase account: a Google account from account.js if there is one, otherwise the same anonymous account the games use (it only signs in, and loads adminkey.js, when someone presses Send). Nothing is saved until Arjun adds the rules below; until then Firestore refuses the write and the box says "Feedback isn't switched on yet" and keeps the message. In the browser it allows one note a minute and five a day (localStorage fbk:sent) and keeps the unsent message, name and reply field (fbk:draft, fbk:name, fbk:contact); those are manners, not security, so the rules check every field. There's no email fallback, because no contact address is published on the site. The game list is GAMES at the top of feedback.js: add new Back Room games there (the rules accept any lowercase folder-style name, so they don't need changing; the admin page's stats also take game names from this list). The notes are read and ticked off on the admin page's Feedback tab (the old feedback-admin/ inbox has been removed).

  • e63334287a3deaa3/door.js: the way into the admin page, on the Back Room page (a <script> tag) and after the home page's reveal() (which loads it next to feedback.js). It's three steps, in order: (1) type the door word into the Party panel's "Party code" box and don't press Join (party.js only reads that box when Join or Enter is pressed, so typing does nothing there; door.js stops Enter from trying to join while the word is in the box, and remembers for two minutes that it was typed, in case the box gets emptied); (2) click or tap the "You found it" line above the title (anywhere on it; the "Y" is a <span data-door> with extra invisible padding) three times within 2.5 seconds. Taps are counted on pointerup as well as click, because iPhone and iPad Safari don't send clicks for taps on plain text. If the word is in the box, or was typed there in the last two minutes, the box is cleared and a number keypad opens (big buttons; on a computer the number keys, Backspace, Enter and Escape work too); without it, nothing visible happens. (3) Enter the admin number. The keypad checks it like the home page checks the Back Room code: the first 16 hex characters of SHA-256 of ascoding11-admin: + the number name a folder, and the browser goes there only if the folder exists; a wrong number shakes the keypad and clears it (three wrong numbers close it). The number is handed to the admin page in sessionStorage (bra:n), which the page reads and deletes at once. The word is checked by its digest too (SHA-256 of ascoding11-door: + the trimmed, lowercased box value, first 16 hex characters, WORD in the file), so neither the word nor the number is in the repo: ask Arjun for the door word, the admin number and the admin password. The door only works while you're not in a party (the Party code box only shows then). The phone page has the Party panel but no "You found it" line, so the door isn't there; open the admin page directly instead (it then asks for the number and the password).

  • e63334287a3deaa3/adminkey.js: the admin key. window.BackRoomAdminKey = {pub, wrapped, salt, iv}: pub is an ECDH P-256 public key (JWK), and wrapped is its private key (PKCS#8) encrypted with AES-GCM (iv) under a key from PBKDF2-SHA256 of <admin number>:<admin password> with the random salt and 600,000 iterations. The private key itself is not in the repo in any other form. window.BackRoomSeal(obj) seals an object to pub the same way social.js seals private messages (a fresh ECDH key pair per entry, AES-GCM), giving 1.<ephemeral public key>.<iv>.<ciphertext> in base64. social.js and feedback.js load the file the first time they seal something. To change the codes, generate a new key pair and wrapped key with a small script (Node's or the browser's Web Crypto) and replace the object; anything sealed before can then only be opened with the old file and the old codes.

  • e63334287a3deaa3/d06440218d1813a3/: the admin page. The folder name is the digest of the admin number (see door.js); it isn't linked from anywhere, isn't in the sitemap, and has the Back Room's noindex,nofollow,noarchive robots tag. Coming through the door, it asks for the password; opened directly, it asks for the number and the password. The page unwraps the admin private key in the browser with both (a wrong password shows "Wrong password"; a wrong number on a direct visit shows "Wrong number") and keeps it only in memory; Lock or reloading forgets it. It reads Firestore (chat, feedback) and the Realtime Database (the Stats tab's leaderboard nodes) with the same anonymous Firebase account the games use in that browser (no Google sign-in), so the lock on the saved chat and feedback is the sealing, not the rules: anyone signed in can download the documents, and only the two codes open them. Once in, it says "Welcome, " (the saved Online name, else a party or game name, else it asks once and keeps the answer in localStorage bra:name). The page is dark, with a slowly flickering field of light-blue binary digits on one canvas (about eight small updates a second, paused while the tab is hidden, and still with reduced motion). Four tabs:

    • Stats, from the Leaderboards' data: one read of rooms/lb-all/players in the Realtime Database (the nodes stats.js publishes from inside every Back Room game; readable by anyone signed in, so no rules to add), checked with leaderboard.js's own parse (the page loads leaderboard.js with data-lib, which only exposes its checks, games table and formatters as window.BackRoomBoards and adds nothing to the page), so a node the boards would skip (stale, from the future, over the caps, malformed) is skipped here too and counted as such. On top of that it reads the fields the boards don't show: ga/gn (the account signed in with Google, and its display name) and d (seconds per day per game for the last 30 days: real dates within the last 30 days only, games the boards know, whole seconds, no more in a day than 24 hours or than that game's total; a range's sum for a game is capped at the game's total). It shows totals (hours, players with time in the range, Google accounts among them, accounts on the boards and how many nodes were skipped), hours by game as a bar list (tap one to filter), and a People table, one row per account: the Google name with a Google badge when ga is set, else the Online name (n), the seat tag worked out from the uid (never the node's st), their avatar (avatar.js) and status tier badge with the miles number (miles.js, from the same checked node), hours, miles, games, top game and last seen (the node's ts), sortable by each, searchable by name, seat or account; a row expands into that person's hours per game for the range (with the all-time total), their bests (as the boards would show them, Slipstream laps and course records included) and a 30-day activity strip (one bar a day, today outlined; the chosen game only when one is picked). Filters: today, 7 days or 30 days (from the per-day data) or all time (from the totals), game, and Google accounts only. Nothing here is a date-bounded query: it's one read, filtered in the browser, and Refresh reads it again. It doesn't read the analytics (pv) or who at all. Each person's details have a Reset hours to 0 button (and Undo hours reset once reset) and a Remove from the leaderboards button for fake or junk accounts (and Put back on the leaderboards): it signs the change with the admin key unwrapped at the door and saves it with leaderboard.js's hours resets, so the boards everywhere count that account's hours from then on; days before the reset drop out of the day views. A person with several devices or browsers that never signed in with Google has an account (row) for each; reset each one. Same person? lists entries that look like one person split over two accounts: the same name (not "Player" or "Guest"), and one has nothing the other doesn't (every game it has, the other has too, with at least as many hours) and was last seen first, usually an anonymous account from before a Google sign-in (stats.js now merges those itself at the sign-in, but not ones from before). Merge signs a for it with the admin key (like a reset), so every board shows the two as one row (per game the larger hours and the better bests, so nothing is counted twice); names aren't unique, so check first. Merged accounts stay in the People table marked Merged and left out of the totals, with Undo merge in their details (entries their owner merged at a Google sign-in say so and have no undo).
    • Players, a read-only view of each account, with a line at the top saying so. One read of rooms/lb-all/players (like Stats; each node checked with leaderboard.js's parse, and nodes the boards skip are still listed, marked Skipped, with only their name and last seen). One row per account (merged accounts are not folded together here): avatar, name (the Google name when ga is set, else the Online name), seat, uid, type (Google when the node carries stats.js's ga mark, else Anonymous; nodes from before the mark count as Anonymous), tier and miles (miles.js), last seen (ts), play time (all time, after any hours reset) and this week (leaderboard.js's week). Searchable by name, seat or uid and sortable by any column; on a phone the uid, type, tier and week columns fold under the name. Tapping a row shows: per-game time with this week's and the bests (as the boards show them); a day-by-day play-time chart for the last 60 days drawn on a canvas (one bar a day, today outlined, point or tap a day for its games; d from the node, checked like Stats, with the _ other-games time included; stats.js only keeps 30 days, so the older half is shaded and always empty); Online now from four single-field reads of rooms/ol-lobby/players/<uid> (ts, p, pl, g: online if the heartbeat is under 75 s old, party size and leader, game), never the whole node, so its lounge and private messages are never downloaded; and the account save mirror size from one read of rooms/sv-saves/players/<uid> (bytes, number of saved keys, last saved; the contents aren't shown). No emails, no messages, nothing beyond those three nodes. Nothing in the tab writes (no set/update/remove/push/transaction), so no rules to add. Refresh reads the list again; each account's extra reads happen once per load, when it's opened.
    • Chat, the safety log in Firestore chatlog (written by social.js): cabin-chat messages and reports, newest first, opened with the admin key, filterable by kind, person (a report also matches the person it's about), words and dates (at most 2,000 per load, and it says when it hits that). Entries that won't open (written by something else, or sealed to a different key) are counted and shown as such. There's no delete: the rules don't allow it (old entries can be deleted in the Firebase console).
    • Feedback, the feedback notes as a to-do list (the newest 500): open ones first, newest first; click a note or its checkbox to mark it done or open again (sets done); filter by game and kind; "Show done" shows the done ones after the open ones. "Hide this sender" hides every note from that uid in this browser only (localStorage fbk-admin:hidden, same as the old inbox). No delete.

    Everything is shown as plain text (never parsed as HTML). A test hook (window.__admin, which swaps Firestore for documents held in the page; sealed ones still need both codes) exists only when navigator.webdriver is set. Who can see what: the sealed parts of chatlog and feedback (message text, names, seats, reply details) need both codes. The rest is readable by anyone using the site's anonymous sign-in once these rules are in: which accounts wrote to the log and when, each feedback note's game, kind and done flag, and the who documents (Online name, seat and analytics visitor ID per account). The Stats tab's data (rooms/lb-all/players) is already readable by anyone signed in, as the Leaderboards need it to be: every player's hours per game and per day, bests, Online name and, for Google sign-ins, the Google display name. Anyone signed in can also tick feedback done or not done. If someone should get the admin page, give them the door word, the number and the password; there's no per-person access.

    Firestore rules to add inside match /databases/{database}/documents { … } (no emails to fill in). The feedback block replaces the earlier feedback block: remove that one first. Before pasting it, look at the existing feedback documents in the Firebase console: notes sent before sealing are plain text, including the optional name and reply details, and this block lets anyone signed in read them, so delete (or copy and delete) any you don't want readable.

    // Back Room safety log (social.js), read on the admin page (e63334287a3deaa3/d06440218d1813a3/).
    // box is sealed to the admin key in e63334287a3deaa3/adminkey.js: anyone signed in can read the documents,
    // only the admin codes open the box. Entries can't be changed or deleted from the site.
    match /chatlog/{id} {
      allow create: if request.auth != null
        && request.resource.data.keys().hasOnly(['kind', 'uid', 'ts', 'box'])
        && request.resource.data.keys().hasAll(['kind', 'uid', 'ts', 'box'])
        && request.resource.data.kind in ['cabin', 'report']
        && request.resource.data.uid == request.auth.uid
        && request.resource.data.ts == request.time
        && request.resource.data.box is string
        && request.resource.data.box.size() > 0 && request.resource.data.box.size() <= 12000
        && request.resource.data.box.matches('1[.][A-Za-z0-9+/=]+[.][A-Za-z0-9+/=]+[.][A-Za-z0-9+/=]+');
      allow read: if request.auth != null;
    }
    
    // One document per account: its Online name and seat and its analytics visitor ID (social.js). Nothing reads it any
    // more (the admin page's Stats come from the leaderboard nodes), so this block is optional; social.js still writes it.
    match /who/{docId} {
      allow create, update: if request.auth != null
        && docId == request.auth.uid
        && request.resource.data.keys().hasOnly(['uid', 'vid', 'name', 'seat', 'ts'])
        && request.resource.data.keys().hasAll(['uid', 'vid', 'name', 'seat', 'ts'])
        && request.resource.data.uid == request.auth.uid
        && request.resource.data.vid is string && request.resource.data.vid.matches('[a-z0-9]{0,40}')
        && request.resource.data.name is string
        && request.resource.data.name.size() > 0 && request.resource.data.name.size() <= 40
        && request.resource.data.seat is string && request.resource.data.seat.matches('[0-9]{1,2}[A-F]')
        && request.resource.data.ts == request.time;
      allow read: if request.auth != null;
    }
    
    // Back Room feedback box (feedback.js). Replaces the earlier feedback block.
    // New notes are sealed (box); the old plain shape is still accepted from browsers with an old copy of feedback.js.
    match /feedback/{id} {
      allow create: if request.auth != null
        && request.resource.data.uid == request.auth.uid
        && request.resource.data.ts == request.time
        && request.resource.data.game is string && request.resource.data.game.matches('[a-z0-9-]{1,30}')
        && request.resource.data.kind in ['idea', 'bug', 'other']
        && (
          (request.resource.data.keys().hasOnly(['uid', 'ts', 'game', 'kind', 'box'])
            && request.resource.data.keys().hasAll(['uid', 'ts', 'game', 'kind', 'box'])
            && request.resource.data.box is string
            && request.resource.data.box.size() > 0 && request.resource.data.box.size() <= 12000
            && request.resource.data.box.matches('1[.][A-Za-z0-9+/=]+[.][A-Za-z0-9+/=]+[.][A-Za-z0-9+/=]+'))
          ||
          (request.resource.data.keys().hasOnly(['msg', 'game', 'kind', 'name', 'contact', 'page', 'uid', 'ts'])
            && request.resource.data.keys().hasAll(['msg', 'game', 'kind', 'name', 'contact', 'page', 'uid', 'ts'])
            && request.resource.data.msg is string
            && request.resource.data.msg.size() > 0 && request.resource.data.msg.size() <= 1000
            && request.resource.data.name is string && request.resource.data.name.size() <= 40
            && request.resource.data.contact is string && request.resource.data.contact.size() <= 80
            && request.resource.data.page is string && request.resource.data.page.size() <= 200)
        );
      allow read: if request.auth != null;
      // Marking a note done or open again changes nothing else. No deletes from the site.
      allow update: if request.auth != null
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['done'])
        && request.resource.data.done is bool;
    }
    

    Nothing needs adding to the analytics pv rules: the admin page doesn't read the page views.

  • joukowsky-dynamics/: only preview.jpg (used by the Engineering cards) and a redirect page. The Joukowsky Dynamics workbench itself is hosted on the Joukowsky-Dynamics repo's own GitHub Pages site, https://ascoding11.github.io/Joukowsky-Dynamics/, and the cards link there. Keep it there rather than copying it into this repo, so there is one copy to update.

  • tideline/: only preview.jpg (used by the Engineering cards) and a redirect page. The Tideline radar-satellite workbench is hosted in the same Joukowsky-Dynamics repo, at https://ascoding11.github.io/Joukowsky-Dynamics/satellite/, alongside its CAD.

  • harvest-steps/: the Harvest Steps workbench (public, in the sitemap, with a card on the Engineering page and in the home page's Engineering card). Unlike the other two engineering projects it lives in this repo: one self-contained index.html (Three.js r128 from cdnjs, like the games) plus preview.jpg for the cards. Five tabs, kept in the address as #sequence, #blueprint, #physics, #market and #prototype: an animated footstep through the tile, an exploded parts view with a datasheet per part (click a part or pick it from the list), an energy calculator, a schematic market day, and a bench prototype build guide (how the circuit works, an SVG schematic, parts and tools, frame assembly, a 12-step soldering walkthrough whose perfboard view is drawn by buildBoard() from PB_PARTS and highlights each step's parts from STEPS, multimeter checks, first power-up, the Arduino logger sketch with calibration, test plan and troubleshooting). All the numbers come from one function, model(): walker mass × g × plate travel, minus the return springs (60 N preload, 15 N/mm), times the gear, generator and electronics efficiencies; the market tab uses the same result, so changing an input on the physics tab changes it too. Visitors (60,000 on a peak day) and the electricity price ($0.29/kWh) are sourced figures named on the physics tab; steps per visitor (6, 60 or 400 for coverage A, B or C) are estimates. Nothing is stored. window.__hs (seek, setTab, setMode, select, showStep) is there for screenshots and tests. Works with touch: drag to turn, pinch to zoom; on narrow screens the panel moves under the 3D view.

  • applied-robotics/: only preview.jpg (used by the Engineering cards). The Applied Robotics Workbench (six SVG circuit sheets for a two-wheel rover, bench calculators, an Arduino sketch and a parts list) is hosted on the applied-robotics repo's own GitHub Pages site, https://ascoding11.github.io/applied-robotics/, and the cards link there. Keep it there rather than copying it into this repo, so there is one copy to update.

  • about/ and the guides (hype-cycle-exchange/guide/, boxed/guide/, docket/guide/, prompted/guide/): plain reading pages, written in our own words, that explain the site and how to get the most out of each public tool (running Hype Cycle Exchange in class, AMC 10 and AIME prep with Boxed, planning study time with Docket, handling supplemental essays with Prompted). They use the .guide styles at the end of site.css, are in sitemap.xml, and are linked from the Projects and Productivity cards ("…guide" buttons) and from the footers (About). No ads on them. When a tool changes in a way the guide describes (rules, limits, modes), update its guide too.

  • docket/: Docket, a study planner for Canvas students (plain JavaScript, no backend; everything is saved in the student's browser). It imports assignments three ways: a bookmarklet that reads the Canvas API from the student's logged-in Canvas tab and hands the data to Docket in the URL hash (its source is the bmSrc block in the page), the Canvas calendar feed / any .ics or Google Calendar .zip export, or pasted text from a Canvas assignments page. It estimates time and difficulty per assignment, then schedules study sessions around calendar events and the student's activities. No ads on this page.

  • prompted/: Prompted, a supplemental essay tracker for college applicants (plain JavaScript, no backend; everything is saved in the student's browser under prompted:v1). Students add schools with a plan and deadline, paste a supplement page to pull out each prompt with its word or character limit and whether it's optional (parsePrompts in the page), write drafts with a live count, save and restore versions, and start new essays from existing drafts. A reuse map groups similar prompts across schools by kind (Why us, academic interest, community, and so on). Backups download as JSON; all drafts download as one .txt. prompted/schools.js is the school library: the U.S. News 2027 top 50 national universities with their 2026–27 supplement prompts (as short summaries in our own words, with real limits), choose-N sets, college- or program-specific prompts keyed to the student's intended major, application plans and deadlines. UC campuses share one application, so they're added as a single school. Update the file each August when schools publish new prompts, and keep every prompt as a summary in your own words: don't paste schools' official prompt text into this repo, even though it's posted publicly. No ads on this page.

  • e63334287a3deaa3/m/: the phone version of the Back Room: every game (the Back Room ones plus Boxed and Hype Cycle Exchange) as a tile, under the same five headings as the computer version (Puzzles, Parkour, Shooter / Action, Multiplayer, Single player; MODES), and grouped by kind under each (each GROUPS row is [kind, games, mode], mode 'puzzles', 'parkour', 'action', 'multi' or 'solo', with the rows of one mode kept together; Boxed is under Puzzles and Hype Cycle Exchange under Multiplayer). The first Puzzles tile is Daily Puzzles (daily, not counted as a game); it opens in the frame like a game, and the daily games it links to open in the same frame (its scope is the whole Back Room folder). After the games, a "Want more games, or have an idea?" note has a Tell Arjun button that opens the feedback bar. A game opens full screen in a frame under a thin bar with the way back, and the last one played shows at the top as Keep playing. The Leaderboards section (leaderboard.js) comes after the games. It can be added to a phone's home screen (manifest.webmanifest, icon-*.png), where it runs like an app; on Android it also goes full screen and turns sideways games sideways. Tiles use the small copies in m/thumbs/ (560 × 350) rather than the full previews. On a phone, entering the code on the home page opens this page in place of the Back Room, still at the home page's address, and invite links (#<room>) open their game straight away. The Back Room page also links to it (data-direct, so the home page opens it as its own page instead of in its game layer); that address is the one to add to a home screen. It isn't in the sitemap or linked from the public pages.

  • e63334287a3deaa3/stats.js: play time and personal bests for the Leaderboards (Back Room). Every active Back Room game loads it with one line in its <head>, <script src="/e63334287a3deaa3/stats.js" data-game="<folder>" defer></script>; it runs inside the game page, so it works the same when the game is opened directly or inside the home page, phone page or party layer. Play time: a second counts while the tab is visible and there was a real key, click, touch, mouse movement or gamepad input in the last 30 seconds (made-up events and the still "mouse moves" browsers send when the page changes don't count); a quiet stretch after that (thinking, a cutscene, waiting for the next round) is held back and counts once the player touches something again within 2 minutes, and is dropped if they don't (they walked away) or the tab is hidden. Only one tab or window of the browser counts at a time, the one with the latest input (localStorage lbs:tab, a claim renewed every second), so two Back Room windows side by side don't count the same minute twice. At most 24 hours a day are counted for all games together. Bests come two ways. Some it reads from what the game already saves (READ in the file): Coil coil:best (longest snake), Shell Crackers shellcr-rank (Elo, once you've played a rated match), Updraft updraft:best (best height, fastest summit), Spire spire:profile (fastest full tower, towers climbed), Tumble Trials tumble-stats (crowns, shows), Showdown sd:pstats (biggest pot and hands played in practice) and Trivium trivium.daily.* and trivium.streak (best finished Daily score, best Daily streak). The rest come from one-line hooks at the moment it happens in the game, window.BackRoomStats && BackRoomStats.add('kills', 1): Shell Crackers kills (practice and online, not the training range), Rumble Karts wrecks and rounds you top, Backrooms 1v1–4v4 kills and match wins (against the Entity and online), deaths in those three games (deaths: Shell Crackers when you're cracked, again practice and online only; Backrooms when the Entity or another player gets you; Rumble Karts whenever you're wrecked, lava included) and, next to each kill hook, kdkills (the same kills or wrecks again, counted from the day deaths started being counted, so the K/D board isn't thrown off by kills from before; each game's own scoreboard also shows this match's or round's K/D next to the kills: Shell Crackers' standings table, the Backrooms results screen and the player's own row in Rumble Karts' results), Boostball goals and match wins, Coil cut-offs, Odd One Out props found and rounds your team wins, Chromaflage hiders found and rounds you stay hidden, and Slipstream's fastest lap per track (BackRoomStats.min('lap:<track id>', seconds), every completed lap, including laps with a track cut: the game's penalties only add to the race time). Bests from a single round or race come from the same kind of hook with max (or min for times), next to the running counts: Slipstream's course record per track and race length (rec:<track id>:<laps>, the full race time with any track-cut penalties added, as in the results, for a race you finished over every lap; onFinish), Rumble Karts' most wrecks in one round (rwrecks, counted per round id), Shell Crackers' and Backrooms' most kills in one match (mkills; Backrooms counts the Entity in practice and players online), Boostball's most goals in one match (mgoals, from the match's own goal count), Chromaflage's most hiders you found in one round and Odd One Out's most props you found in one round as a hunter (rfound), Odd One Out's survival as a prop in seconds (survive, the game's own res.surv: when you were found, or the whole hunt if you weren't), Spire's fastest full obby (obby, all 49 stages in one go from stage 1; a run continued from a checkpoint doesn't count) and Tumble Trials' most crowns in a row (crownrun, solo and online shows, counted within one visit to the page). The hooks only record; they don't change the games. The API is add(stat, n) (a running count, at most 100 at a time), min(stat, value) (a time: lower wins) and max(stat, value); stat names are short lowercase words (a-z, 0-9, :, -), and a name starting with lap: or rec: or listed in LOWER (tower, summit, obby, total, and the daily puzzles' fewest, guesses, tries, fastest, fast-easy, fast-medium, fast-hard) is lower-wins; LOWER is one list for every game, so a name in it must mean lower-wins in every game that uses it. At most 120 stats are kept per game (Slipstream can have a lap and three course records on each of 26 tracks). Counting once, and only up: the browser keeps the seconds it hasn't sent yet, and each publish sends them as a batch that is added to the node's totals in a Firebase transaction (an atomic read-modify-write), never its own idea of the total. So two devices or browsers on one account add up, instead of each writing its own total over the other's (which made totals go up and down and kept only the larger device's time), and a browser never hands its time to the next account it signs in with. Each batch has an id (its time in ms) and the node keeps the last batch id from each device (q), so a batch that reached the database but wasn't marked sent (the page closed mid-way) is never added twice; a batch made under another account (a sign-in or sign-out since) goes to the current account unless that account's node already has it. Totals never drop below what the browser last saw committed (hw), so an older copy of stats.js still open somewhere can't lower them for good. Saved in the browser under localStorage lbs:v2 ({un: {<folder>: seconds not sent}, ud: {YYYYMMDD: {<folder>: seconds}}, d: day, ds: seconds counted that day, b: {<folder>: {<stat>: best}}, tc: {<folder>: seconds this browser ever counted}, out: the batch being sent {u, id, g, d}, hw: [[uid, {<folder>: seconds}]…] (last committed totals, 4 accounts), v1, lk, lkd}), lbs:dev (this browser's device id) and lbs:uid (the account it last published under). Before this version the browser kept running totals in lbs:v1 ({g: {<folder>: {s, <stat>}}, d, ds, dy}), shared by every account used in that browser; on the first load of this version its bests become b and its seconds are folded in once, into the account in lbs:uid keeping the larger (the old rule), or added to the first account it publishes under if it never published. lbs:v1 is left alone. Published at most once a minute while something changed, and when the tab is hidden or the page closes (if already connected; otherwise the batch waits in the browser for the next publish of any game), to the Firebase Realtime Database (same project and rules as the games) at rooms/lb-all/players/<uid> (prefix lb, not an invite prefix): the shared rules only let a browser write its own node, need n, c, x, z, h, ts (x/z/h always 0) and refuse names longer than 14 characters, so the node is n (the Online name ol:name, else the Google account's first name, else the party name, else a game's saved name: the same order as the Online panel; a rename on the Back Room page reaches an open game as a storage event and the game republishes about 1.5 s later, at most every 10 s and only once it is already connected), c (colour), ts (server time), v (2; nodes still written by the older version say 1 and read the same), st (the seat tag from social.js, for convenience; readers work it out from the uid), g ({<folder>: {s: seconds, <stat>: number}}, s being the account's total over every device), d (play seconds per day per game for the last 30 days, {YYYYMMDD: {<folder>: seconds, _: seconds}} by the browser's local date; at most 30 days and 8 games a day, and the time in any further games that day goes into one _ entry for all of them (DAY_REST; before it, a ninth game's time was simply left out of d), so a day's total is complete for the Leaderboards' This week board even when it isn't split by game past the first eight; at most 24 hours a day, _ included; the oldest days are dropped until the whole node fits in 7,000 characters, under leaderboard.js's 8,000 limit), q ({<device id>: last batch id}, the 12 most recent devices), lg ({<folder>: seconds}, the totals as they stood when this version first wrote the node: "legacy" time, which may overlap between accounts used in one browser, so a merge keeps the larger of it instead of adding), al ({<uid>: ms}, anonymous accounts merged into this one, at most 8), to (on an anonymous account's node: the account it was merged into) and, only when the account it publishes under is a Google sign-in (not anonymous: the hub page's account.js sign-in that party.js reuses, or the game's own Firebase user), ga: 1 and gn (the Google display name, 40 characters, never the email or photo URL). The admin page reads d, ga and gn (it skips _, which isn't a game); the Leaderboards read d for This week and ignore ga and gn. Older copies of stats.js still open somewhere drop _ when they next write the node (their cleanDays doesn't know it), which only loses that overflow time from d, never from the totals. The node is never removed on disconnect, because it is the player's entry; clearing the browser doesn't wipe it either (only unsent seconds live in the browser). The older version overwrote the whole node, so while a tab with it is still open it can briefly lower a total or drop q, lg and al; the next publish of this version puts the total back. Linking accounts: the games sign in anonymously, and signing in with Google on a hub page makes a new account, so before this version the anonymous account's entry stayed on the boards as a second row with the same name (its hours were usually also in the Google entry, because the browser's record was shared). Now account-nudge.js keeps the anonymous user the hub page had and, the moment a Google sign-in arrives on that page, loads stats.js with data-lib and calls BackRoomStatsLib.link(): it writes to: <Google uid> on the anonymous node while it can still act as that account (a second Firebase app in memory, given that user with updateCurrentUser), then the Google node merges it in its next transaction (time counted since batches is added, legacy time keeps the larger, bests the better, al[<anon uid>] set; done once, and remembered in lbs:v2 as lk/lkd). leaderboard.js leaves an entry out only when both sides agree, so nobody can hide someone else's. Entries split before this version can't be linked this way (the anonymous sign-in is gone from the browser); the admin page suggests them and can merge them. Firebase: inside the home, phone or party layer it borrows that page's sign-in (window.parent.Party), so it's the same account the panels show (looked up again at every publish, so a Google sign-in on the hub page while a game is open is followed); if the game is online it uses the game's own Firebase; otherwise, after a minute, it loads the SDK in a hidden same-origin frame and signs in there (the same saved sign-in, so the same uid), so it never touches the game's own Firebase globals (Trivium's are another project). Everything is self-reported by the player's own browser, so someone with developer tools can write any numbers into their own node; leaderboard.js checks and caps what it shows, but it isn't cheat-proof. To add a stat for a new game: load stats.js in the game with its folder name, then either add a reader to READ for something the game already saves or add a one-line hook where it happens, and give the game a row in GAMES in leaderboard.js. A test hook (window.__lbs) exists only when navigator.webdriver is set.

  • e63334287a3deaa3/leaderboard.js: the Leaderboards (Back Room). A "Leaderboards" section under the games on the Back Room page and the phone page (one <script defer> in each page's <head>; the home page's reveal() also loads it, because the home page doesn't run the Back Room page's <head>). A Board menu picks "Miles (all games)" or a game. The Miles board (the first one, view.g 'all') ranks players by miles, the same "miles with us" as the status tiers (miles.js): rank, avatar, name · seat, tier badge and the game played most, then the miles ("12,450 mi") with the hours played under them. A This week / All time toggle sits beside the Board menu; it opens on This week, and once someone picks the other it's remembered in the browser (lb:span, 'week' or 'all'). All time is BackRoomMiles.of(node).miles, the number behind the badge (until miles.js has loaded, play-time miles only; the board redraws when it arrives). This week is miles from play time only, 100 an hour (BackRoomMiles.HOUR), over the last 7 days: today and the 6 days before by the viewer's clock, plus tomorrow (a player in a time zone ahead), from the per-day history d that stats.js keeps on each node (seconds per game per day, keyed by the player's own local date). Wins and bests carry no date, so they can't be put in a week honestly and only count in All time; a line under the board says so. Each day is checked like the hours: at most 24 hours a day, only games the boards know (at most 12 a day; stats.js keeps 8) plus _ (the day's time in any further games, below); a game's week can't be more than its all-time hours, nor the week more than all the hours; players with nothing this week aren't on it. After an hours reset only days from the reset's date count, still capped at the hours since the reset, so a reset zeroes the week straight away. Merged entries take per game the larger week, like the hours. Parse adds wk ({ <game>: seconds, _ }), week (seconds) and wtop (the game played most this week) to each player. The same toggle switches each game's Hours board between this week's and all-time hours (not its other boards: a best has no date). Days kept before stats.js had _ lost the time of a ninth game and on in a day, so a few heavy days from late October 2026 count a little short in This week (all time is unaffected). Each game shows its boards as buttons (Hours first, then the game's bests, best single rounds before all-time counts) and opens on the first one anyone is on: Rumble Karts most wrecks in a round, wrecks and round wins; Shell Crackers Elo, most kills in a match, kills and K/D; Backrooms most kills in a match, kills, K/D and match wins (Rumble Karts also has a K/D, wrecks over times wrecked); Slipstream, which opens on every track's fastest lap (one row per track, all 26: the fastest lap there, who set it and how many drivers have a time; a track with none says "no times yet"; tapping a track opens its own board), then, with a Track menu (tracks with times first, most drivers first, picked once the data is in, so it isn't stuck on the first track in the list), that track's course record for each race length (3, 5 and 10 laps, the lengths the game offers, plus any other length someone has a record for: "3-lap record"; these boards add a small line, "Race times include any track-cut penalties (+2.5 s each).") and its fastest lap; Boostball most goals in a match, goals and match wins; Coil longest snake and cut-offs; Trivium best Daily score and streak; Showdown biggest pot and hands played (practice); Chromaflage most hiders found in a round, hiders found and rounds stayed hidden; Spire fastest tower, fastest full obby and towers climbed; Updraft fastest summit and best height; Odd One Out most props found in a round, longest survival as a prop, props found and rounds won; Tumble Trials crowns, most crowns in a row and shows. Times show as m:ss.mmm (survival as m:ss), counts with their unit ("12 kills"). K/D (all time) is worked out by the board, never read from the node: kdkills / max(1, deaths) to 2 decimals, both checked counts (each at most half a kill a second played, and kdkills no more than the all-time kills), ranked only with at least 20 kills and capped at 100.00; deaths and kdkills aren't boards of their own (hide in GAMES; the admin page still lists them). A remembered board that has nobody on it gives way to the game's first board with someone, but a board picked by hand on the page stays even when empty. Each board shows the top 25 and, if you're further down, your own row after a gap; your row is highlighted with a "You" tag (you are the account party.js signs in with, or the one stats.js last published under). The choice of board (and Slipstream track) is remembered in the browser (lb:view, {g, b, t}). It reads nothing until the section is near the screen (or a board is picked), then reads rooms/lb-all/players once; Refresh reads it again (at most every 8 seconds). It signs in through party.js (window.Party). Every node is untrusted and checked before it's shown: at most 3,000 players shown and 8,000 characters a node; nodes from the future, from before the boards started (EPOCH, 21 October 2026) or not updated for 180 days are skipped (the timestamp is checked first, so a stale node costs one comparison and doesn't count toward the 3,000; entries are never removed from the database, so every view still downloads them all); play time can't exceed 24 hours for every day since EPOCH (a node claiming more is skipped); running counts can't exceed a rate per second played in that game (rate in GAMES, e.g. half a kill a second), and each stat has a range (Elo 100–3000, Updraft height up to 1005 m and a summit no quicker than 2 minutes, a Spire tower no quicker than 20 s and the full obby no quicker than 2 minutes, a Slipstream lap no quicker than the track's length, as the game builds it, at 120 m/s, the game's hard speed cap (VMAX 92 m/s × 1.3, which boost and DRS can't pass; real best laps average 60–80 m/s), from TRACKS); a best single round has a cap (most kills in a Shell Crackers match 30, the first to 25 wins; Backrooms 80; Rumble Karts wrecks in a 3-minute round 60; Boostball goals in a match 40; Chromaflage hiders in a round 7 and Odd One Out props 15, a full room less one; Odd One Out survival 900 s, the longest hunt) and can't be more than the player's all-time count it's part of (le; it's dropped if there's no such count); a Slipstream course record needs 1–20 laps, must be no quicker than the track's quickest lap times the laps and no quicker than the player's own fastest lap there times the laps (every lap of a race is also a lap time, so a record with no lap time is dropped); unknown games, stats and tracks are ignored; at most 120 stats per game; names are cleaned, run through social.js's word filter and only ever set as text. To add a game, add a row to GAMES (folder, name and its stats: key, label, format, unit, range and, for running counts, a rate, or for a best single round the all-time count it can't exceed); for a new Slipstream track, add it to TRACKS. low: 1 ranks a count lowest first (the daily puzzles' fewest-guess boards; the key must also be in stats.js's LOWER), grp: 'Daily Puzzles' puts a game in that group of the Board menu (the menu has a Games group, then Daily Puzzles), and rateOf: 'daily' checks a count against the seconds of every Daily Puzzles game together (the hub's done, as the hub page is only open for seconds). A node with more than 80 games is skipped (stats.js keeps at most 80, MAX_GAMES; it was 40 before the daily puzzles). A test hook (window.__lb) exists only when navigator.webdriver is set. Loaded with data-lib (the admin page does this), it exposes its checks, GAMES, TRACKS and formatters as window.BackRoomBoards and stops before adding anything to the page. One person, two accounts: an anonymous entry whose owner merged it into a Google account (its own to, and the Google node's al, both written by stats.js) is left out, since the Google entry already has its hours; an entry the admin merged into another (a signed a, below) is shown as one row with it, per game the larger hours and the better bests (collapse, also used by the admin page, which lists merged entries marked). Hours resets: play time can be set back to zero from the admin page. Totals only ever grow (stats.js adds each device's new seconds and never lowers a total), so a reset is a zero point: the seconds each game had at that moment, subtracted from then on (personal bests are untouched). Resets are stored at rooms/lb-rs/players/<admin's uid> as r, the JSON of { <uid>: { t, b: { <game>: seconds } } } (no b means the reset was undone), with sig, an ECDSA P-256 / SHA-256 signature of r made with the admin key (the same key pair as adminkey.js's ECDH one). Anyone can write a node there, so only nodes whose signature checks out against the public key in adminkey.js count, and for each uid the latest t wins. The same entries can also remove an account from the boards altogether (h: 1, for fake or junk accounts): every board skips it, while the admin page still lists it, marked Removed and left out of its totals, with a button to put it back; or say an account is the same person as another (a: <uid>, one row; one step only, so a merged account can't itself be a target). Older copies of leaderboard.js ignore a, to and al and show every entry.

  • e63334287a3deaa3/gamechat.js: in-game party chat (Back Room). Every multiplayer game loads it with one line in its <head> (<script src="/e63334287a3deaa3/gamechat.js" defer></script>): Shell Crackers, Rumble Karts, Slipstream, Boostball, Coil, Showdown, Backrooms 1v1–4v4, Chromaflage, Spire, Turbo Trail, Escapade, Updraft, Odd One Out, Tumble Trials, Deepword and Puffer Party. It does nothing unless the game is open inside a Back Room page whose Online panel offers window.BackRoomChat (the home page's game layer, the phone page, or party.js's own layer) and the player is in a party with someone else; a game opened on its own shows nothing new. Then the party chat shows as a small feed on the left (about a third of the way down; fewer lines on a short screen) that fades after 9 seconds and never takes clicks, with a one-time hint when the game opens. T opens a box to type in (Shell Crackers and Turbo Trail use Enter and Chromaflage Y, because T is one of their keys: data-key on the script tag, by e.code), Enter sends, Esc, the × or a click outside closes it; on a phone, the Chat button that social.js adds to the game's top bar opens the same box, so there's nothing new over the game's own touch controls. Spire's script tag has data-left="72" so the feed sits right of its progress bar. While the box is open the game is kept out of it: the keys and mouse buttons the game thinks are held get a keyup / mouseup first; every key event stops in a capture listener on window (added after the game's own, so only keybinds.js's capture listener, which only acts while its Controls panel is open, runs first), which leaves typing working; pointer lock is let go and asked for again when the box closes (Enter and clicks are user gestures, so it comes straight back), and the game's own pointerlockchange for that unlock is held back so it doesn't open its pause menu; if the browser won't give the lock back, the game is then told the mouse is free and shows its own pause or "click to play"; a see-through layer over the game catches clicks and taps. The key isn't taken while the player types in one of the game's own fields, or when they've moved one of the game's actions onto it in the Controls panel (read from keybinds:<game> in localStorage); the top-bar Chat button still works then. Everything goes through social.js (encryption, block, mute, word filter, rate limit), so this file never touches Firebase and stores nothing. The box and feed live in a shadow root, so the game's CSS and this file's CSS don't mix.

  • Archived Back Room games: e63334287a3deaa3/starhop/, quarry/, kartwheel/, abyssal/ and hail-mary/ are kept in the repo but taken off the Back Room page, the phone page (GROUPS in m/index.html) and the Parties list (GAMES in party.js). Their files, saved data and keybinds still work if someone opens the folder directly, and their prefixes stay in the invite routing (an old invite link opens nothing rather than the wrong game). The Online panel still names someone who is in one (GAMES in social.js, listed without room, so it offers no room invites), and the admin page keeps their names for old feedback notes (SITE in its script); they're left out of the feedback box's game list (GAMES in feedback.js). To bring one back, restore its card, its GROUPS row, its feedback-box entry, its room in social.js and (for Kartwheel, Quarry and Hail Mary) its GAMES row in party.js from git history.

Touch: every game plays on a phone. Each game draws its own touch controls (a move stick, buttons, and drag-to-look for the first-person games) and shows them only on touch screens (pointer: coarse, or after the first touch), so the keyboard-and-mouse version is unchanged. The first-person games (Shell Crackers, Backrooms 1v1–4v4, Quarry) skip pointer lock on touch screens; in Quarry, taps in the inventory and crafting screens go to the same code as mouse clicks.

  • functions/api/traffic.js: GET https://ascoding11.com/api/traffic, the showcase page's live traffic numbers (last 30 UTC days including today, in the analytics dashboard's "Copy showcase numbers" shape: anonymous totals, no names, emails or visitor IDs; the homepage is left out of the top pages, and sessions made only of 0-second homepage views with no referrer are treated as bots). It reads the analytics pv pageviews with the shop's FIREBASE_SERVICE_ACCOUNT secret, so no Firestore rules change. To keep reads low it saves each finished day's summary in traffic_days/{YYYY-MM-DD} once and reuses its last answer (traffic_days/_latest) for 30 minutes; only this function touches traffic_days. showcase/index.html loads it (from ascoding11.com when opened on github.io) and shows its saved trafficData snapshot only if it can't be reached.
  • store.js and functions/ (site root): the shop, for selling skins and other items with Stripe. Buying needs a Google sign-in, and an item belongs to that Google account on every device and on both addresses. A page loads <script src="/store.js" defer></script> and calls Store.buy(sku) from a click (signs the player in with Google if needed, then sends them to Stripe's own payment page), Store.owned() / Store.has(sku) to see what they own, and Store.returned() / Store.waitFor(sku) when Stripe sends them back (?purchase=success&sku=…). The server side is three Cloudflare Pages Functions, which run only on ascoding11.com (GitHub Pages can't run code), so the shop on ascoding11.github.io calls them there: functions/api/checkout.js (checks the Google sign-in, refuses items already owned, starts a Stripe Checkout), functions/api/stripe-webhook.js (Stripe's signed "payment finished" message: the only thing that gives an item) and functions/api/owned.js; shared code in functions/_lib.js. Items are Stripe prices: an item exists when an active price has its sku as the lookup key (lowercase letters, digits and _, starting with a letter, e.g. boostball_flame), and its price comes from Stripe. Purchases are written only by the webhook, with a Firebase service account, to Firestore entitlements/{uid} (items: { sku: time bought }) and orders/{Checkout session id}; the site's pages never read or write those collections directly. Secrets live only in Cloudflare (the site's Settings → Variables and secrets): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, FIREBASE_SERVICE_ACCOUNT (the whole JSON key). The Stripe webhook endpoint is https://ascoding11.com/api/stripe-webhook with checkout.session.completed and checkout.session.async_payment_succeeded. Refunds don't take items back automatically.
  • adrefresh.js (site root, so the public games can load it without linking to the Back Room): loads a fresh ad in the ads that are showing (menu banners, side rails) when someone clicks or taps a button on the screen. Keyboard presses don't count (their clicks have detail 0), nor do the in-game touch controls (anything in #touch, .tb/.tbtn, Coil's #boostBtn, Hail Mary's #tA/#tB, or anything marked data-no-ad-refresh), and it refreshes at most once every 30 seconds, only on screen and only ads that were filled. AdSense doesn't allow timed refresh, only refresh on what the person does, and these limits keep it that way. Every game page with ads loads it with <script src="/adrefresh.js" defer></script> in its <head>; with no ad slots set up it does nothing. Ads: Google AdSense runs only on the game pages. The home page carries just the google-adsense-account meta tag, which verifies the site with AdSense without loading any ads. ads.txt (site root) and privacy.html are required by AdSense. Hype Cycle Exchange uses automatic ads. Boxed has no ads: its menu banner and side rails were removed while the site is under AdSense review, because its problems come from other sites (the AoPS Wiki); to bring them back, copy the AD_SLOT block and side rails from another game. The other games show banners only on their menu and results screens, switched on by pasting a display ad unit ID into AD_SLOT near the top of each game's script.

The game's secret results file (rounds.json) is intentionally not in this repository. Keep it on your own computer and choose it when you host a game.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages