Changelog
Versions follow SemVer: MAJOR.MINOR.PATCH.
Each entry summarizes what shipped. When a change came from playtester feedback, it's called out as such so the link from "real player ran into X" → "fix Y" stays visible.
To cut a release: bump version in package.json, add an entry below, then vercel deploy --prod. The /rules page renders this file automatically.
Working agreement: every user-visible change in a commit must land with a ## Unreleased bullet in this file. Cutting a release renames ## Unreleased to ## X.Y.Z — YYYY-MM-DD and starts a fresh empty ## Unreleased section. See AGENTS.md ("Changelog discipline") for the full rule.
Unreleased
-
The iOS catalog frames itself by scene and walks you through how cards fill in, like the web. The phone catalog now opens with a first-run walkthrough of how a sticker climbs from rumor to spotted to collected (reopen it any time from the new "? HOW IT WORKS" button in the masthead), and a scene switcher up top once you've unlocked more than one city. Picking a scene re-skins the whole page in that city's colors and shows only its cards, the same as the web. The first city you unlock gets Maestro Magnus explaining what cities are, then each new scene gets its own "New region, unlocked" celebration flyer. Files:
app/api/catalog/route.ts(the catalog payload now carries each card'sregionIdand the region list),ios/.../Views/CatalogView.swift(region scoping + theming, the onboarding primer, the city primer, the discovery celebration, and a region palette mirroringlib/regions/palette.ts). -
The iOS battle music is a full setlist now, with a one-time "want the soundtrack?" nudge. On the web, the in-battle speaker chip flips open into a setlist of every track the fighters on stage have cleared for play, so you can pick a song or skip to the next instead of being stuck with whatever started. The phone only ever played the first track it found, with no way to browse or switch. The native chip now pulls the same deduped setlist and gains a caret: tap it to flip open the list, pick any track to play it, or hit Next to skip ahead. And because the radio starts muted, a first-time player had no way to know there was music at all. Once per device, after the fight gets going, the phone now shows the same one-time card the web does ("Want the soundtrack?", with the band and track cued up named), so the soundtrack gets surfaced instead of staying hidden behind a muted chip. Files:
ios/.../Audio/BattleAudio.swift(fetches/api/battle/tracks, shuffles, tracks the current index, exposes skip/play-track and the prompt-seen flag),ios/.../Views/BattleAudioControl.swift(the flippable setlist chip + the radio prompt),ios/.../Views/BattleView.swiftandios/.../Views/PvpBattleView.swift(drop in the chip and the prompt). -
iOS gets a "My bands" door, like the web. The web side menu shows a "My bands" entry to any artist who plays in a band, a front door to editing their band's page. The phone's menu didn't have it. The native drawer now shows the same entry when you own a band (the same
/api/mesignal the web reads), opening the band editor in Safari for now since the phone has no native band screen yet. File:ios/.../Views/HomeView.swift(theownsBandprobe + the "My bands" drawer row). -
You can edit your band from the iOS app now, not just your profile. On the web, an artist who's claimed their page gets a "My bands" list and can edit each band's description and links from /me/band. The phone could edit your personal profile but had no way to reach the band. Your profile screen now lists the bands you play in (right under your links), and tapping one opens a band editor with the same description and links fields the web has. Name, genre, and color stay with your booker, same as the web. The phone saves through the same ownership-gated API as the web editor, so a band you don't play in stays off-limits. Files:
app/api/me/band/[id]/route.ts(new GET/PUT JSON route mirroring the web'supdateMyBandserver action),ios/.../Views/MyBandView.swift(the editor),ios/.../Views/MyProfileView.swift(the "My bands" list + nav door). -
The iOS battle turns sideways and plays as stickers now, like the web. The web rebuilt the battle into a landscape stage: turn your phone, and your band and the other band square off as peel-stickers, each member a genre-washed disc with their HP and BPM curving around the base in two stacked arcs instead of stat bars. The phone still showed the old cramped portrait card list. The native battle now matches the first slice of that redesign. Open a fight in portrait and a "Turn your phone sideways to take the stage" card waves a rotating phone glyph, then clears the moment you rotate; in landscape the two bands face off as stickers, with the concentric HP/BPM arcs cradling each disc and the status badges peeled along the top. This is the first piece of a larger port (issue #270): the move tray, top bar, tappable status explainers, and the rest land in follow-up changes. Files:
ios/.../Views/RotateToBattle.swift(the rotate gate),ios/.../Views/CharacterSticker.swift(the sticker tile + theConcentricSemiArcsmeter),ios/.../Views/BattleView.swift(the landscape stage),ios/BattleOfTheBands/project.yml(battle landscape allowed). -
The iOS app can turn a guest into a real account, and it knows how to reset a forgotten password. On the web, a guest sees "Make it permanent" once they're playing and can convert without losing the bands they built, and the sign-in screen has a "Forgot password?" link that mails a reset. The phone had "Play as guest" and nothing after it: no way to claim the account, no way back in if you forgot your password. iOS settings now show "Make it permanent" for guest sessions (it upgrades the same account in place, bands and all), and the sign-in screen carries the same "Forgot password?" affordance that mails the reset link. Both hit the exact Better Auth endpoints the web uses. Files:
ios/.../Views/UpgradeAccountView.swiftandForgotPasswordView.swift(new),ios/.../Auth/AuthService.swift(requestPasswordReset),ios/.../Auth/SessionStore.swift(tracks guest vs. real),ios/.../Views/SignInView.swift+RootView.swift(the entry points). -
Casper counts as one musician now, not two. He plays lead guitar in Hollowstem and guitar and vocals in Opening Day, both on the Jun 10 bill at The 808, and the catalog had him down as two unrelated people, one of them misspelled "Caspar". They're a single artist now. Finding him with either band reveals both of his cards, hearting him once files him once, and claiming his artist profile covers every band he plays in rather than half of them. He still gets his own picture in each band, because card art belongs to the show it was made for, not to the musician. File:
lib/db/migrations/0046_merge_casper_person.sql(repoints the Opening Day card at the surviving artist, carries catalog finds and hearts across, retires the duplicate). -
The iOS tutorial catches up to the web's onboarding. The native Maestro Magnus tutorial had drifted behind the web one. It now matches: a third starter band, INFERNO (hip-hop), joins Luana and the Iguanas and nopeople, so the three picks form a circle where whoever you sign, your first rival is one you can out-punch. Magnus asks your name in the intro and calls you by it the rest of the way (a blank name still falls back to "MANAGER"). Synergy now goes live in stage 3, the moment your band first fields three of the same genre, instead of waiting for the full five. The new-bandmate reveal cards are tappable: tap one to open "Maestro's tour," that bandmate's spotlight, the same as the web. The tutorial now sits at the top of the Play menu as well as in the side menu, and a brand-new account drops straight into it after sign-up. Files:
ios/.../Views/TutorialView.swift(third starter + type-cycle opponents, name capture and vocative, stage-3 synergy, tappable reveal card + tour blip),ios/.../Views/PlayFlow.swift(Tutorial entry on the Play menu),ios/.../Auth/SessionStore.swift+ios/.../Views/SignInView.swift+ios/.../Views/RootView.swift(new accounts route into the tutorial). -
The third starter band is a real act now: Inferno. The hip-hop slot in the starter select was a placeholder crew called The Cipher: five invented names with no faces, standing in until a real band could take the spot. Inferno (Inferno Records) takes it, with real cutouts: R1xxy, GeoFire, JVN, and D3V, four rappers and producers. They keep the hip-hop synergy and hold the same place in the matchup cycle, so picking them still lines you up against a rival you can out-punch. Inferno is a four-piece, so the tutorial counts your lineup now instead of assuming five: the professor names however many bandmates actually turn up, and your debut show fields as many rivals as you have players rather than quietly putting a four-piece against a five-piece. Files:
content/roster.ts,lib/roster/canon.ts,lib/tutorial.ts,lib/cutouts.ts,app/_components/magnus-theme.tsx,lib/db/seeds/canon-roster.ts,public/cutouts/. -
The app icon is back to the original red pick-and-bolt mark. The flat black pick from the mid-June rebrand didn't stick. Reverted to the first custom mark (red pick, black outline, yellow bolt, cream background) everywhere: browser tab, home-screen icon, and the iOS app icon. Files:
app/icon.png,app/apple-icon.png,app/favicon.ico,public/brand/icon.png,ios/.../Assets.xcassets/AppIcon.appiconset/icon-1024.png. -
You can delete your account from the iOS app too, not just the web. Settings has a Danger Zone now, same as web: a Delete Account button that asks for your password (or, for a guest account, just a confirmation) and warns there's no undo before it wipes the account and everything tied to it. Files:
lib/auth/account.ts(delete logic shared with the web flow),app/api/account/route.ts(the endpoint the app calls),ios/.../Auth/AuthService.swift,ios/.../Auth/SessionStore.swift,ios/.../Views/RootView.swift(the Danger Zone panel inSettingsView). -
Sticker art shows up on catalog cards again. Cards for members with minted sticker art (the Hollowstem roster, for one) were rendering a broken-image icon instead of the sticker. The art lives in our cloud file store, and the image optimizer refuses any host it hasn't been told to trust, so those cards never loaded. The store is on the trusted list now and the stickers render. File:
next.config.ts(images.remotePatterns). -
The catalog stopped showing the same sticker twice. Each opponent band used to repeat its whole lineup in the catalog once per show it had played, identical stickers under two different dates, as if a new sticker run were printed for every gig. Stickers don't work that way: a sticker is made once and belongs to the show it was made for. The catalog now shows each sticker exactly once, under its own show, and a band's other gigs stay in its show history without spawning duplicate cards. Files:
content/shows.ts(hasStickerSuitemarks the suite-bearing show),lib/db/seeds/canon-roster.ts(mints one suite per band),lib/db/migrations/0045_drop_per_show_sticker_suites.sql(retires the seeded duplicates, keeping any minted sticker resolving). -
There's a Support page now, at
/support. A plain place to get help: how to reach us, why your bands live on your account and not the device, audio and friend-battle troubleshooting, and how artists can get their profile or tracks updated. It's the support link the iOS App Store listing points at. File:app/support/page.tsx. -
iOS battle copy stopped using em dashes. The native app had drifted from the web on a few strings: the Auto battle-mode blurb and the "synergy active" combat-log lines still carried em dashes the web had already dropped. They now read the same on both clients. Files:
ios/.../Views/PlayFlow.swift,ios/.../Views/BattleView.swift,ios/.../Views/TutorialView.swift. -
You can delete your account now. Settings has a Danger Zone at the bottom with a Delete Account button. It sits collapsed so a stray tap can't wipe your save; opening it spells out what goes away (every band, battle, and catalog find tied to the account) and, for a real account, asks for your password before it goes through. There's no undo. Deleting removes your account and all of its data in one sweep, then drops you on the sign-in screen. A guest account can delete itself too, and it warns that a guest has no email to recover from. Files:
lib/auth/server.ts(enable Better Auth'sdeleteUserendpoint),app/settings/actions.ts(thedeleteAccountserver action),app/settings/delete-account.tsx(the Danger Zone panel),app/settings/page.tsx(wired in). -
The battle radio plays a set now, not one song on repeat. Battle music used to loop a single track for the whole fight, so a long battle meant hearing the same 60 seconds over and over. The radio now runs the full setlist (every eligible track from the bands in the fight): a song plays out, crossfades into the next over about three seconds, and the list loops back to the top so the music never runs dry. It loads the next track partway through the current one so the handoff has no gap. A lone track still loops, the speaker chip still mutes, and the setlist caret still lets you pick a song or skip ahead (a skip now slides over instead of cutting). Files:
lib/audio/use-crossfade-playlist.ts(the new crossfading playlist engine),app/_components/battle/battle-audio.tsx(wired the chip to it). -
The home logo sits right on big screens and sideways phones now. On a wide desktop window the wordmark used to strand small against the left edge with a lake of empty masthead beside it; it now centers and scales up to fill the broadsheet, lined up with the menu below. Held sideways on a phone, the tall masthead used to shove Play off the bottom of the screen; the logo now shrinks to the short height and the masthead tucks in so the menu stays in view. File:
app/_components/title-screen.tsx(per-view logo sizing via theuseIsShortLandscapehook, centered withmargin: 0 auto). -
The app icon is the guitar pick now. On the home screen and browser tab, Battle of the Bands shows the pick mark from the new logo: the rounded pick in ink with the punk-red lightning bolt across it, on cream paper, no wordmark. It's the pick from the masthead logo, redrawn clean as a standalone mark with a fresh bolt. Files:
app/icon.png+app/apple-icon.png+app/favicon.ico(web, replacing the oldicon.svg),ios/.../Assets.xcassets/AppIcon.appiconset,public/brand/icon.png(source). -
Battle of the Bands has a logo now, on the web and the phone. The home screen used to spell the name in big italic type. It's now a stamped guitar-pick mark with a lightning bolt through it and the name knocked out underneath. It's monochrome ink with one punk-red bolt, so it sits inside the zine's "ink and a single spot color" look instead of fighting it. The same mark is the hero on the public alpha landing page, and on iOS it's the splash you see while the app wakes up and loads the roster. Files:
public/brand/wordmark.png,app/_components/title-screen.tsx+app/_components/alpha-landing.tsx(the masthead and hero image),ios/.../Assets.xcassets/Wordmark.imageset,ios/.../Views/HomeView.swift(masthead),ios/.../Views/RootView.swift(LaunchSplash). -
The iOS profile fields stop at the same limits as the web. The web profile editor caps your stage name at 120 characters, pronouns at 60, and bio at 2000, the same limits the server enforces, so you find out as you type rather than when a save bounces. The native editor let you keep typing past those, and a too-long bio would only fail at save. The phone now stops you at the same per-field limits while you type. File:
ios/.../Views/MyProfileView.swift(amaxLengthmodifier on the stage name, pronouns, and bio fields). -
The iOS profile editor now tells you where your edits show up. On the web, the "Your profile" editor on /me carries a small line under the title, "Shown on your public page at /p/your-slug", so an artist knows the bio and links they're editing are the ones the world sees. The native editor didn't say. It now shows the same line at the top of the form (the server sends your page slug), so the connection between "edit here" and "this is my public page" is just as clear on the phone. Files:
app/api/me/route.ts(GET/PUT returnslug),ios/.../Views/MyProfileView.swift(the /p/{slug} subtitle). -
iOS notification channels are chips now, and the phone channel reads "Mobile app (coming soon)". The native settings screen toggled each delivery channel with an iOS switch, while the web shows them as small tap-to-toggle chips (filled when on, a check in front), and the phone-push channel was labeled "Phone push" on iOS but "Mobile app (coming soon)" on the web. The native screen now uses the same chips, wrapping to a second row when they don't fit, and the channel labels come from the server so the phone reads "Mobile app (coming soon)" exactly like the web. Files:
app/api/notifications/preferences/route.ts(channels carry web's labels + anote),ios/.../Views/NotificationSettingsView.swift(FlowChips/FlowLayoutreplace the toggles). -
iOS notification settings now show the "Always sent" list, like the web. The web settings page ends with a small footer spelling out the messages we always send (your password-reset link, your artist-agreement receipt) and noting they can't be turned off, so the toggles above don't read like the whole story. The iOS settings screen only showed the tunable toggles, leaving those required emails invisible. It now closes with the same "Always sent" card: the transactional types, locked on, each with its one-line reason. The list comes from the server (the same catalog the web reads) so the two can't drift. Files:
app/api/notifications/preferences/route.ts(GET now returns analwaysSentarray of transactional types),ios/.../Views/NotificationSettingsView.swift(the "Always sent" card). -
The catalog reads like a sticker folder now, not a Pokédex. A card's physical condition tells you how close you are to owning it. A rumor is a beat-up old card: foxed yellow paper, a worn-off corner, a crease, and a figure blurred past recognition, the kind of card that's been at the bottom of the shoebox for years. A spotting is the same card in better shape, lightly handled, the real figure showing through worn soft, with the night and the room you crossed it in printed underneath. A card you own is mint: crisp ink border, the raised sticker shadow, full color, fresh from the pack. The class and genre colors only light up on the mint card, so a page full of unknowns stops shouting and the one you've earned is the one that pops. The old look (a faint dashed ghost for rumors, a heavy charcoal slab for spottings) is gone, and the catalog's how-it-fills-in walkthrough teaches the new beat-up-to-mint progression. Files:
app/catalog/_components/catalog.tsx(cardConditiontokens, the per-condition card render, foxing/crease/torn-corner wear overlays),app/catalog/_components/catalog-onboarding.tsx(sample cards + copy). -
Your friend handle shows up in the iOS friends screen now. If you've set a Discord-style handle (Name#0001), the web app shows it as a second way for people to add you, next to your random friend code. The iOS friends screen only ever showed the code. It now shows your handle right under it, tap to copy, the same as the code. Setting or changing the handle still happens on the web for the moment; this just surfaces it on the phone so the tag you picked is visible where you'd actually share it. Files:
ios/.../Realtime/SocialStore.swift(decodehandle/discriminatorfrom/api/player/social, exposehandleTag),ios/.../Views/FriendsView.swift(the tag under YOUR CODE). -
You can set and change your friend handle from the iOS app now. Picking your Name#0001 tag used to be a web-only thing; the phone could show it but not set it. Settings on iOS now carries a "Friend handle" editor: type a name, the number gets assigned for you, and you get the same answers as the web (a saved tag, "that name is full," or "2 to 20 characters: letters, numbers, spaces. No # symbol."). Files:
app/api/player/handle/route.ts(new POST wrapping the same save logic the web uses),lib/social/server.ts(saveFriendHandleForUser, shared by the web server action and the route so the copy can't drift),ios/.../Realtime/SocialStore.swift(setHandle),ios/.../Views/RootView.swift(the Settings editor). -
The catalog only offers a scene picker once you actually have a second scene. "The scene" switcher used to sit at the top of the catalog even when you'd only unlocked one region, a chooser with nothing to choose. It stays hidden until you've unlocked more than one scene; the single region still skins and scopes the page exactly as before. File:
app/catalog/_components/catalog.tsx(region switcher gated onregionsWithCards.length > 1). -
On iOS, a Feedback Squeal rig no longer hangs over an enemy all match. When you rig an opponent so their next swing backfires, the web game gives the rig a clock: it lapses after two of the rigged enemy's own turns if they just play it safe and never attack into it. The iOS app was missing that clock, so a rig the enemy never triggered sat armed for the rest of the fight, which is not how the same move behaves on the web. iOS now ticks the rig down the same way it ticks Charm: two of the rigged character's turns and it lapses, while swinging into it still backfires instantly. Files:
ios/.../Game/Engine.swift(tickFeedback, ticked inmoveActor; the backfire clears it),ios/.../Game/Game.swift(feedbackTurnson the combatant,FEEDBACK_TURNS = 2, armed on cast). -
The native catalog gets the web's "Reveal all" preview toggle. The web catalog has long carried a small REVEAL ALL switch in the masthead that drops every silhouette and shows the full roster, art and names and all, so you can browse what's out there before you've earned it. The iOS catalog masked everything you hadn't collected with no way to peek. It now has the same toggle next to the collected count: tap REVEAL ALL and every card flips to its full state (the count reads N / N), tap REVEAL ON to drop back to your real collection. The choice sticks across launches, and it shares the exact key the web uses (
aux:catalog:reveal-all). File:ios/.../Views/CatalogView.swift(@AppStorageoverride instate(for:), masthead toggle button). -
The iOS app now fights by the same tuned numbers as the web game. The native client carried its own hand-copied combat constants, so every time the web game was retuned (class-scaled basic attacks, the 15% bump to crowd-wide sweeps, per-card crit and accuracy like Dax's striker edge) the phone quietly kept playing by the old numbers. The app now pulls the live balance snapshot at launch, the same one the web game reads, and feeds it straight into the battle engine. A Practice or AI show on your phone resolves crit and miss curves, synergy bonuses, class-scaled basics, AoE damage, and looper growth exactly the way the web game does, and an admin retune reaches both clients without shipping a new build. If the fetch fails the app falls back to the shipping-default numbers, which is what it used to hard-code anyway. Files:
ios/.../Game/Game.swift(theBalanceSnapshotmodel, baseline, andgetBalance()accessor; synergy + basic-damage math read it),ios/.../Game/Engine.swift(crit/miss, AoE, and looper read it, plus the per-card levers),ios/.../API/RosterStore.swift(fetches/api/balance, decodescritMod/missModper character). -
A four-piece is a real band now, and it fights a fair fight. The band builder used to make you fill all five slots before it would save, which left the classic four-piece (and the lone-DJ-plus-two) out in the cold. You can save a band at four now, no more than two of any one role, same as before. The catch a quartet would otherwise hit is the stage: five-on-four means a body down, fewer turns, and faster losses, and you can't paper over a missing turn with bigger numbers. So instead of buffing the four, an empty slot fills at showtime with a guest "session player" who sits in (a genre-neutral generalist with average HP, a plain basic, and a modest solo), and the fight runs as a symmetric five-on-five. The guest is clearly marked "sitting in" on the stage, never counts toward your band's genre synergy, and never lands in your catalog or audio: it's a warm body for the empty chair, not a sixth bandmate. Files:
lib/game.ts(MIN_TEAM_SIZE,makeGuest/fillTeamToFull, relaxedisTeamValid),app/_components/battle/battle.tsx+lib/realtime/server.ts(pad each side at build time, synergy from the real lineup),app/_components/battle/character-sticker.tsx(the "sitting in" marker),app/catalog/_draft-band.ts+app/catalog/_components/band-builder-overlay.tsx(save at four, guest-fill hint on the empty slot). -
Tapping a new bandmate in the tutorial opens their tour now, instead of skipping Magnus ahead. When Maestro Magnus reveals a fresh recruit, the whole screen advanced his dialogue on a tap, so anyone who reached out to poke the shiny new character card just fast-forwarded the conversation by accident. The reveal cards are real buttons now (they carry the raised ink shadow to say so), and tapping one pops a small blip with a single door: "Maestro's tour," which opens that bandmate's spotlight (their card, their special, a one-on-one demo) in a new tab so the tutorial you're in stays put underneath. Tap the card again or tap away to dismiss the blip. File:
app/tutorial/page.tsx(CharacterRevealCardis a button now,CharacterRevealBlipis the callout linking to/tutorial/spotlight/<id>). -
Characters can trade muscle for a sharp edge now, so the bench has fewer dead weights. Crit and miss used to be the same for everyone (a flat 10% to land a big hit, 8% to whiff), tuned only by class and matchup. That left no room for a scrappier character to be worth fielding: if two players filled the same role and one had more HP and a meaner special, the other was just a worse copy nobody drafted. Cards now carry their own crit and accuracy on top of the global base, so a glassier fighter can pay for it by critting harder or whiffing less. The clearest casualty was Dax (nopeople's rhythm guitar), a strict downgrade from Lee on every number. Dax keeps the lighter frame and the smaller hype buff, but his Mic Tap basic now crits 12 points more often and misses 3 less, so he plays like a striker instead of a spare Lee. Operators get a live "power" read in the member editor that scores a card against its slot's budget and flags anything that's strictly outclassed, so the next Dax gets caught before it ships. Files:
lib/battle/engine.ts(crit/miss read the per-card mods),lib/balance/card-power.ts(new scoring + domination check),content/roster.ts(Dax),app/admin/appearances/_components/VersionForm.tsx+[id]/page.tsx(the editor inputs, power meter, and flags), schema/migration0043_card_crit_miss_mods. -
Tap an opponent before picking your move to see their stats. During the attacker phase, every living enemy character is now tappable even before you've chosen a move. A quick-inspect card pops up showing their name, HP, BPM, and any active status effects (Stage Rush, Feedback, and so on), so you can plan before committing. Tap anywhere outside to dismiss. Files:
app/_components/battle/character-sticker.tsx(CharacterInspectModal,inspectableprop),app/_components/battle/battle.tsx(inspectedEnemyUidstate,focusEnemyrouting,CharacterInspectModalrender). -
Valid targets now get marker brackets drawn around them. When a move enters target-select phase, every legal target picks up four hand-scrawled corner brackets in the space around their cutout, like someone circled the target on the stage with a marker. It fits the battle board's look (everything on it is either a sticker stuck down or a doodle drawn on) better than the old crosshair stamped over the figure, and it still makes clear at a glance who you can aim at. File:
app/_components/battle/character-sticker.tsx(TargetBrackets, rendered whenclickable). -
The demo battle plays its soundtrack by default. The showcase battle at
/demonow starts with the radio on, so a first-time visitor hears the bands scoring the fight without hunting for the speaker chip. Your own mute choice still sticks once you make one, and real play is unchanged (it still starts muted with the one-time "want the soundtrack?" prompt). Files:app/demo/page.tsx(startAudioUnmuted),app/_components/battle/battle.tsxandapp/_components/battle/battle-audio.tsx(startUnmuteddefaults the mute state when there's no saved preference). -
Tapping a move you can't afford tells you why. Special attacks used to silently ignore taps when you didn't have the BPM for them. Now tapping an unaffordable move shows a brief message: "Not enough BPM — it builds each turn." The button stays visually locked but is no longer HTML-disabled, so the tap goes through and the message lands. Files:
app/_components/battle/battle-tray.tsx(bpmMsgstate +showBpmMsg),app/_components/battle/move-button.tsx(lockedprop separates visual-disabled from HTML-disabled). -
The taunt move is now called Stage Rush. "Taunt" is fighting-game jargon that didn't match the music-battle setting. The mechanic is the same: rush the front of the stage, soak the next single-target hit for your band, then snap back. The name now describes the action. Files:
lib/battle/status-effects.ts,lib/format.ts,lib/battle/engine.ts,app/_components/battle/phase-row.tsx,app/_components/battle/character-sticker.tsx. -
Jake's special is Double Kick, not Floor Tom Pound. Renamed to a move that sounds like a move. File:
content/roster.ts. -
The acting character appears center-stage during each attack. When any combatant executes a move, their emoji and name pop up in a raised card at the center of the stage while the action resolves. The original sticker dims so your eye goes straight to the card. Files:
app/_components/battle/battle.tsx(actingUidstate,actingCombatantoverlay),app/_components/battle/character-sticker.tsx(isActingprop). -
The tutorial lives in the Play menu now. Maestro Magnus's guided onboarding was buried in the hamburger nav next to Rules and Credits, easy to miss for a new player who hit Play looking for somewhere to start. It now sits at the top of the Play menu as its own card ("Tutorial: meet a starter band and learn every move, one show at a time"), above Practice and Jam Session, so the first thing you see under Play is the thing that teaches you how to play. It's gone from the nav drawer to avoid two doors to the same room; the
/tutorialroute is unchanged. Files:app/_components/play-menu.tsx(new Tutorial card +onTutorialprop),app/_components/game-shell.tsx(routes it to/tutorial),app/_components/global-nav.tsx(dropped the nav entry). -
From playtester feedback: Magnus stops calling Harmony pure healers. In the tutorial's three-slot recap, the Band Professor summed up your Harmony as one who "can heal," which sold the class short: a Harmony might buff or boost instead of heal. The line now reads "can heal or buff," matching how Magnus already introduces the slot a stage earlier ("They HEAL, BUFF, or BOOST their bandmates"). Files:
lib/tutorial.ts,ios/.../Views/TutorialView.swift. -
The band-builder role filters stack now. The Rhythm/Melody/Harmony/Wildcard/DJ tabs at the top of the roster used to be one-at-a-time: tap Rhythm and you saw only drummers, with a separate "All" tab to get back to everyone. They're toggles now, so you can light up Rhythm and Harmony together and see both pools at once. With nothing selected the roster shows everyone, which is what "All" used to do, so the All tab is gone. Files:
app/_components/team-builder.tsx(the filter is aSetof slots; empty means show all). -
The native app's band builder and changelog caught up to the web. Two iOS parity fixes. The native band builder's role tabs now stack the same way the web's do, with no "All" tab (an empty selection shows everyone). And the changelog moved off the bottom of the Rules screen onto its own "Changelog" page in the menu, matching where it lives on the web. Files:
ios/.../Views/TeamBuilderView.swift(additiveSet<ClassFilter>),ios/.../Views/RulesView.swift(split outChangelogView),ios/.../Views/HomeView.swift(menu entry + route). -
From playtester feedback: Feedback Squeal costs more and can't sit armed all match. Rigging an enemy so their next swing backfires on themselves was too strong for its price, and worse, the rig waited forever: if the rigged enemy just stopped attacking, the trap hung over them for the rest of the fight with no clock. Two changes. The squeal now costs 6 SP (up from 4), the priciest debuff in the game, so it's a real commitment instead of a cheap turn-one rig. And the rig now decays: it stays armed for 2 of the rigged enemy's own turns, so an opponent who plays it safe and never swings shakes it loose, the same way Charm counts down. Triggering a backfire still clears it instantly. Files:
content/roster.ts(cost 6),lib/game.ts(feedbackTurns,FEEDBACK_TURNS),lib/battle/engine.ts(tickFeedbackdecay on the rigged actor's turns; arm with turns on cast; clear on backfire). -
Your basic attack hits for what your role is worth now. Every character threw the same basic ("Comp") for a flat 18 damage, tank or not, even though the classes already split hard on defense: a rhythm tank soaks 150 HP, a melody DPS only 90. The basic now scales with class. A tank chips for 13, support for 14, a wildcard holds at 18, and a DPS burns for 23. It lines a character's offense up with its defense, so a glass-cannon DPS actually swings like one and a tank earns its HP by chipping instead of trading even blows. Specials are untouched (they were already class-shaped: tanks taunt and shield, DPS drops AoE), and DJs, who have no class and steal their moves, are unaffected. The numbers live in the balance snapshot, so they're tunable from the admin balance editor and apply to every existing character without a reseed. Files:
lib/balance/types.ts+baseline.ts+validators.ts(the newbasicDamageblock),lib/balance/active.ts+server.ts(backfill so older saved snapshots read complete),lib/game.ts(basicDamageFor, applied inbuildCombatants),app/admin/balance/_components/balance-editor.tsx(editor section), and the character spotlights (app/tutorial/page.tsx,app/tutorial/spotlight/[characterId]/page.tsx). -
Tutorial rivals go down a little easier now. The opponents Magnus throws at you while you're still learning the ropes were already scaled down, but the first few shows could still grind longer than a teaching fight should. Enemy HP in the tutorial drops another notch across all four stages (and the per-fighter floor eases too), so the rookie, the duo, the trio, and your 5v5 debut all land a touch softer. The real game (Practice, Jam Session, On Tour) is untouched. File:
app/tutorial/page.tsx(ENEMY_HP_SCALEand the HP floor). -
Synergy goes live in the tutorial the stage it gets explained. Stage 3 is where Magnus teaches synergy: recruit your third bandmate, all three share a genre, and the dialog promises a band-wide bonus. But the bonus used to be all talk until stage 4, so the stage that explained synergy fought without it. Now the moment your three-piece walks on (genre threshold met), the synergy banners swoop in the same way they do in a real show, the live synergy chips light up in the top bar (your +50% heal, the rival trio's +15% damage), and the bonus is actually in play. The reveal holds the first turn until it's done, just like the main game. Stages 1 and 2 stay synergy-free, since a one or two-piece can't trip a synergy anyway. File:
app/tutorial/page.tsx(playerSyn/enemySyncompute atstage >= 3,SynergyRevealoverlay +revealDonegate). -
From playtester feedback: the round-one synergy banners wait for you to rotate now. If you started a fight while holding the phone in portrait, you'd see the "turn your phone sideways" prompt, but the synergy reveal was already playing (and timing out) behind it. By the time you rotated, the banners had come and gone, and the battle had quietly started without you. The reveal now holds until you've actually turned the phone, so it plays once the stage is in front of you, and the first turn waits for it. Files:
app/_components/battle/battle.tsx(gate the reveal on the rotate prompt),lib/orientation/use-is-landscape.ts(useIsRotateGateUp),app/_components/battle/rotate-to-battle.tsx(share the gate condition). -
Crowd-wide attacks hit harder. The moves that wash over the whole enemy line (Stadium Wave, Crusher Roar, Tearjerker, Iguana Roar, any all-enemies special) were splitting a damage pool thin enough that a four-cost swing across a full band often felt softer per head than a plain chip attack. Every AoE move's pool now lands 15% heavier before it's divided, so leaning on a sweep reads like the statement it's costing you. The bonus is a single balance knob (
aoe.damagePct), so it tunes the whole class at once from the admin balance editor. Files:lib/balance/types.ts+lib/balance/baseline.ts(theaoe.damagePctfield),lib/battle/engine.ts(scales the AoE pool),app/admin/balance/_components/balance-editor.tsx(the editor control). -
Iguana Roar hits like the rest of the sweeps now. Luana's all-enemies roar was dealing 28 across the line while every other crowd-wide special landed 50 or more, so the Iguanas' frontwoman swung a four-cost AoE that chipped softer than a basic attack. It now deals 50, in line with the other sweeps, instead of sitting as a lone underpowered outlier. Under the hood, every special is now declared as a named skin of one of the standard attacks (
content/standard-attacks.ts), so a move that doesn't tune a number inherits the in-line default and can't drift out of range unnoticed the way this one had. File:content/roster.ts(Luana's special is nowskin("aoe-damage", "Iguana Roar")). -
Maestro Magnus explains what cities are the first time you unlock one. Unlocking a new city already threw up a celebration flyer, but nothing said what a city actually was or why you'd want more of them. The first time any city opens up, the Band Professor steps in ahead of the flyer with a quick read on the map: every city is its own scene with its own bands and sound, you open one by touring through it or scanning a band from there, and an open city widens your Practice pool and fills out your catalog. It plays once, then every later unlock goes straight to the celebration. Files:
app/_components/city-tutorial-overlay.tsx(new),app/catalog/_components/catalog.tsx(RegionDiscoveryGateshows the primer before the first flyer). -
The catalog explains how it fills in now. New collectors hit a wall of "???" cards with no idea what they meant or how to turn them into real cards. The first time you open the catalog, a quick walkthrough lays out the three states a sticker moves through: a Rumor (a band you've brushed up against, masked and untappable), a Spotted card (you know the night and the room, you just don't know the face), and a Collected card (full art, name, and the show it commemorates, ready to draft into a band). It shows a sample of each so the silhouettes on the page actually read. It pops once, then a "? How it works" button in the masthead reopens it whenever. Files:
app/catalog/_components/catalog-onboarding.tsx(new),app/catalog/_components/catalog.tsx(masthead button + first-run gate). -
You can pick a friend handle now, not just live with the random code. Adding a friend used to mean reading off a 6-character code like
xk7m2p. You can still do that, the code and its QR aren't going anywhere, but Settings now has a "Friend handle" section where you choose a name (your band name, your nickname, whatever) and we tack a number onto it, Discord-style: the first person to grab "Riff" becomesRiff#0001, the nextRiff#0002, so two people can use the same name without stepping on each other. You only pick the name; the number is assigned for you. On the Friends panel your handle shows up top with the code tucked under it, and the add-a-friend box takes either one, so a friend can punch inRiff#0001instead of squinting at a code. Files:app/settings/friend-handle.tsx+app/settings/actions.ts(saveFriendHandle),lib/social/server.ts(setFriendHandle, plusaddFriendByCodenow resolves aName#1234),lib/social/types.ts(formatHandle),app/_components/friends-ui.tsx, schema/migration0042_friend_handle. -
From playtester feedback: the status stickers on your band explain themselves now. During a fight, little stickers peel along the top of each fighter when they pick up an effect (Feedback, Detune, a shield, hearing damage, a charm, and so on), but they were decoration: a player who got hit with "Feedback" had no idea what it meant and bounced off. Every one of those stickers is now a real button. Tap it and a short card pops up with the effect's name and a plain-language read on what it does to that character ("Feedback: their next attack screeches back into the monitors and lands on themselves"), tap anywhere outside or "Got it" to dismiss. Tapping a sticker only opens the explainer, it never counts as picking that fighter as your target, so you can read what's on an enemy mid-turn without committing a move. Works the same on the practice stage, in PvP, and in the tutorial. Files:
lib/battle/status-effects.ts(new: one registry holding each effect's glyph, color, name, and blurb so the chip you tap and the card you read can't drift apart),app/_components/battle/character-sticker.tsx(badges lifted out of the targeting button into their own tappable overlay + the explainer card). -
From playtester feedback: a mute reads as a mute now, not a frozen turn. Mute is supposed to be a hidden landmine: you plant it on a random foe, nobody knows which, and it springs when that foe tries to act. The stage was getting both ends wrong. While the mute was armed it wrongly showed a 🤐 badge on the muted fighter, spoiling the secret, and when it finally sprang, all the player saw was a fizzled turn and a "+0", which read like the game had hung. Now an armed mute shows nothing (the way it always should have), and the moment it springs the fizzling fighter pops a clear "🤐 Muted!" over their head so the dead turn reads as the trap working. Same on the practice stage, in PvP, and in the tutorial. Files:
lib/battle/status-effects.ts(mute no longer surfaces a badge while armed),lib/battle/engine.ts(the spring fires a labeled "🤐 Muted!" float),lib/battle/types.ts+app/_components/battle/character-sticker.tsx(floats can carry text, not just a number). -
Artists can edit their own band's page now, not just their profile. Claiming a character got you the
/meprofile editor; your band's blurb and link-outs (the bandcamp, spotify, instagram row on/b/[id]) were still admin-only. The menu now shows "My bands" for any account whose character plays in a band, listing every band you're in. Pick one to edit its description and links, the same way you edit your own bio. It's scoped on purpose: name, genre, color, and home region stay admin-only, since renaming moves the labels on stickers already printed and genre/region shift who you battle and who sees you. Ownership is the key, not a shared intake link: you can only touch a band you actually play in. Files:app/me/band/page.tsx+app/me/band/[id]/page.tsx(the list and editor),lib/bands/ownership.ts+lib/auth/band.ts(the "do you play in this band" check),app/api/me/route.ts(the nav signal),app/_components/global-nav.tsx(the menu door). -
DJs have to earn the move they steal now. A DJ used to walk on stage already holding the special moves of the two enemies across from it, free to spin either one from turn one. That made the copycat a turn-one threat with no setup. Now sampling costs a turn: the DJ's tray shows a "Copy" action for each special it can lift ("Sample: Pogo Mob"), and using one spends the turn learning the move (no damage, the DJ deals nothing that turn) so it can spin it on a later turn, where it flips to a "Mix" button. Until then the DJ leans on Scratch, its plain chip attack, which is always available. The pool it may copy from is still locked in at the start of the fight (the two foes directly opposite, kept even after they're knocked out); what's changed is that owning a copied move is now something the DJ works toward instead of starting with. Files:
lib/game.ts(copyableSpecialsvscopiedSpecials, thecopymove kind,movesFor/moveByKey),lib/battle/engine.ts(the copy action),lib/battle/ai.ts(DJs spin what they've learned, else sample the strongest move they can, else Scratch),app/_components/battle/battle-tray.tsx(Copy/Mix/Scratch labels), plus a turntable-scratch sound for the new move (scripts/generate-combat-sfx.mjs). -
If you're an artist in the game, there's a door to your own profile in the menu now. Players whose account is linked to a character (a real local musician who's been added to the roster) had a profile editor at
/me, the place to fix your bio, links, and audio, but no way to reach it: you had to know the URL. The menu drawer now shows "Edit my character" for those accounts, right under Account, and it's invisible to everyone else. Operators do the linking from the admin side: each person's page now has an "Account" panel to connect a character to the account that should own its/meeditor (by email) or disconnect it, the by-hand counterpart to the emailed claim link. Files:app/_components/global-nav.tsx(the conditional nav entry, gated onGET /api/me),app/admin/people/[id]/page.tsx+app/api/admin/people/[id]/claim/route.ts(admin link/unlink),lib/people/claim.ts. -
From playtester feedback: a head-to-head opponent who vanishes can't strand you anymore. Quitting a live match already told the other player you'd left, but a quit only counts if the message gets out. A force-quit, a dead battery, a dropped signal, or the phone reaping a backgrounded tab all skip it, and the opponent was left waiting on "Foe is choosing…" with no move ever coming and no way to know it wasn't coming. Each turn now carries a clock: if the player on the clock doesn't act within 60 seconds, the server hands the set to whoever's still there and ends it as a walk-off, the same "Your opponent walked off. You take the set." you'd see from a clean quit. In Jam Session that forfeit settles the match rating like any other result, and if the win lands while you've stepped away from the app, you get a notification that the set is yours. The check rides the connection the waiting player already holds open, so it costs no new background polling. Files:
lib/realtime/types.ts(turnDeadline),lib/realtime/server.ts(forfeitExpiredTurn+ a durable forfeit push),app/api/realtime/[roomId]/route.ts(resolve an expired turn on the live poll),lib/matchmaking/server.ts. -
There's a third starter band, and now every starter starts on the front foot. The tutorial used to offer two bands (nopeople and Luana and the Iguanas), and whichever you signed, you faced the other one. That was a coin flip: nopeople's pop out-punches the Iguanas' alt, but the Iguanas got the reverse and walked into a fight they were weak against. A third band, The Cipher (hip-hop), closes the loop into a circle: nopeople beats the Iguanas, the Iguanas beat The Cipher, The Cipher beats nopeople. Your first rival is now always a band your pick is strong against, so the opening show feels winnable no matter who you choose. The starter-select screen fits three cards, and Magnus explains the circle when you pick. (The Cipher ships as a placeholder lineup, its members and art are stand-ins until a real local hip-hop act fills the slot.) Files:
content/roster.ts(The Cipher + members),lib/tutorial.ts(third starter + the matchup cycle),lib/roster/canon.ts,lib/db/seeds/canon-roster.ts,app/_components/magnus-theme.tsx,app/tutorial/page.tsx. -
You can back out of the attack tray now. Once you picked a fighter and the Basic/Special/Mix buttons slid up, there was no way to change your mind short of confirming a move (the confirm step had a "Back" link, but the move-pick step didn't). The move row now leads with an ✕ button that drops the chosen fighter and returns you to the top of your turn, so you can pick a different bandmate or pass. Files:
app/_components/battle/battle-tray.tsx. -
From playtester feedback: there's a front door to your bands now. You could make a band, but afterward there was nowhere to go just to look at the ones you'd made, rename one, swap a member, send it on tour, or retire it. That whole screen already existed, it was only reachable as a step inside starting a Practice match, framed as "pick a band to play," not "here are your bands." The home menu's "Build a Band" button is now "My Bands," and it opens that screen as a destination: every band you've made with its win/loss record, edit, delete, and tour controls, and a "+ Create a new band" button right at the top so making one is still a single tap. Picking a band there heads into a match the same as before. Files:
app/_components/title-screen.tsx(the menu button),app/_components/game-shell.tsx(goMyBands, context-aware Back). -
From playtester feedback: band-buff moves fire again. The Down Beat (Down Beat Devin) and Root Notes (Anchor Anika) are band-wide moves: one pools everyone's BPM and splits it evenly, the other hands every bandmate a shield. There's nobody to aim them at, but the battle screen treated them like a single-target attack, so selecting one dropped you into "pick a target" mode with no valid target to pick. The orb just sat there lit and re-tapping did nothing. Both moves now fire the moment you select them, the same way the all-enemies moves already did. Same fix on the tutorial and PvP stages. Files:
app/_components/battle/battle.tsx,app/tutorial/page.tsx,app/_components/battle/pvp-battle.tsx(handletargetSide: "all-allies"in the move-pick handler). -
Everyone counts in from zero now. Bands used to walk on stage already holding 3 BPM, enough to fire off a real move on the opening turn. Now every character starts at 0 and the turn-start regen ticks them up to 1 before their first action, so turn one is a warm-up: you've got 1 BPM to spend and you build from there, a beat per turn (still capped at 10). It makes the early rounds about pacing into your kit instead of opening with your biggest hit, and it applies the same way on web and iOS. Files:
lib/game.ts(START_SPEED),lib/balance/baseline.ts(speed.start),ios/BattleOfTheBands/BattleOfTheBands/Game/Game.swift(START_SPEED). -
The changelog has its own page in the menu now. It used to ride along at the bottom of Rules, so "what's new" was buried under "how the game works" and you had to scroll past every mechanic to reach it. The menu drawer now lists Changelog as its own stop (right under Rules), pointing at a dedicated
/changelogpage; Rules is just the rules again. The iOS Rules screen still carries both for now. Files:app/changelog/page.tsx(new),app/rules/page.tsx(changelog removed),app/_components/zine-markdown.tsx(new: the shared zine markdown renderers both pages use),app/_components/global-nav.tsx(Changelog destination). -
The battle radio introduces itself now, so the music actually gets heard. The speaker chip starts muted (browsers won't let sound autoplay until you tap something), which meant a lot of fights ran in silence because nobody knew there was a soundtrack waiting behind the chip. Now, once per device, the first time you reach a fight with cleared audio, a small card slides in right after the round-one synergy banners: "Want the soundtrack?", with the track that's queued up named, and a "♪ Play it" button. Tapping it both turns the music on and counts as the gesture browsers require, so the track starts immediately; "Not now" (or tapping outside the card) dismisses it. Either way it never asks again, and it only ever shows while you're still muted, so turning audio on once retires it for good. The chip and its Setlist dropdown work exactly as before for muting and switching tracks. Files:
app/_components/battle/battle-audio.tsx(the prompt +revealDonegate),app/_components/battle/battle.tsx(passesrevealDonethrough). Local dev also seeds two demo tracks so the/demofight has music to play (lib/db/seed.ts). -
Making an account drops you into the tutorial. A brand-new account used to land on the title screen with no nudge toward learning the game. Now creating one (the "Make account" path on the Backstage Pass screen) sends you straight into Maestro Magnus's onboarding battles. Signing into an existing account still goes to the main menu, and upgrading a guest account to a permanent one does too (you've already been playing), so only first-time accounts get the tutorial. A link that passes its own
?next=destination still wins. Files:app/account/page.tsx. -
The battle music is a setlist you can flip through now. The speaker chip during a fight used to lock onto one random track for the whole battle. It now pulls every track the two bands have cleared for play, and when there's more than one a caret next to the speaker opens a "Setlist" dropdown: pick any track to play it, or hit Next to skip to the next one. It still opens on a random track so fights don't all start the same, and a band with only one cleared track shows just the chip (no menu) like before. The picker reads from a new
GET /api/battle/tracks?slugs=...that returns the deduped, eligible setlist for the fighters (a band-attributed track counts once, not once per member); swapping tracks is seamless because playback already runs through the gapless Web Audio loop. Files:lib/audio/server.ts(listEligibleTracksForSlugs),app/api/battle/tracks/route.ts(new),app/_components/battle/battle-audio.tsx(playlist + dropdown). -
iOS gets the real friends system: codes, QR, who's online, and challenges. The native Friends screen was still the old rivals list (everyone you'd ever fought in PvP, read-only). It now matches the web: an explicit, mutual friend graph you build yourself. Your 6-character code sits up top with a QR beside it (tap the code to copy), and you add someone by punching in their code. Friends split into Online and Offline (online means they've used the app in the last few minutes, the same server-side presence the web reads), and each one carries your head-to-head record. Tap Challenge on a friend (pick which band you're bringing if you have more than one) and it sends them a live PvP challenge; if it's accepted, both of you drop straight into the same battle, you as host, them as the joiner. Incoming challenges surface from anywhere in the app: a red banner across the top with Accept and decline, plus a count badge on the home-screen Friends button, so a challenge pulls you onto the stage no matter what screen you're on. The whole thing runs off one poll (
GET /api/player/social) on an app-scoped store that tightens its cadence while the Friends screen is open and pauses entirely when the phone backgrounds. Files (underios/):Realtime/SocialStore.swift(new: snapshot poll, presence, challenge mutations),Views/FriendsView.swift(rewritten: code + QR, add-by-code, online/offline list, challenge + accept band picker),Views/RootView.swift(challenge banner + auto-present the battle),BattleOfTheBandsApp.swift(inject the store),Views/HomeView.swift(challenge badge),API/APIClient.swift(messagehelper). -
From playtester feedback: the landscape battle stage finally has room for the band. On a real phone the band cutouts were still squeezed to a sliver under the action tray, because the tray (the combat log, the move readout, and a big confirm button) was eating most of the short landscape strip. Two fixes: the confirm button, its label, and the move readout now scale down on a short viewport so the tray takes only the height it needs, and on a short landscape screen the combat-log row drops out entirely (the floating damage numbers on each cutout still narrate the fight), handing that space back to the stage. While verifying this, a quieter bug surfaced: the rules that were supposed to hide the per-member names and the audio chip's text label on a short phone (added last change) were being silently dropped by the CSS build, so they never actually hid. That hiding now runs from JavaScript off the same orientation check, so it works. And on a short landscape phone the whole top bar (band names + synergy bonuses + menu) now drops out the moment you pick a fighter and the action tray opens, giving the band that strip too; it comes back between turns so you can still reach the menu and read the matchup. (The battle music keeps playing while it's hidden, the bar stays mounted, just not drawn.) Files:
app/_components/battle/battle-tray.tsx(confirm/readout scale with the viewport),app/_components/battle/battle.tsx(drop the log row + hide the top bar while the tray is open),app/_components/battle/battle-top-bar.tsx(hiddenprop),app/_components/battle/character-sticker.tsx+app/_components/battle/battle-audio.tsx(JS-driven hiding),lib/orientation/use-is-landscape.ts(useIsShortLandscape),app/globals.css(remove the dead media query). -
From playtester feedback: the landscape battle stage stopped colliding with itself. With the move tray now on screen, the band row up top was still a mess on a real phone: the HP/BPM arcs ballooned to nearly twice the cutout size and overlapped each other, the member names ran through the arcs, and the "Tap to play battle music" chip and synergy badges sat on top of the band. The arcs were holding a fixed 120px width while the cutouts had shrunk, and a sideways phone is wide enough to trip the app's "roomy" breakpoint even though it's only a few hundred pixels tall. The arcs now scale with the cutout so they cradle the base instead of swallowing it; on a short landscape screen the per-member names and the audio chip's text label drop away (the move tray already names your target, e.g. "Hit Jake"); the top bar got an opaque backing so a tall band can't bleed up behind the menu and synergy chips. Files:
app/_components/battle/gauges.tsx(arcs take a CSS-length width),app/_components/battle/character-sticker.tsx,app/_components/battle/battle-top-bar.tsx,app/_components/battle/battle-audio.tsx,app/globals.css(short-landscape media query). -
From playtester feedback: the landscape battle fits on a real phone now, move buttons and all. On an actual phone in landscape (not a desktop window shrunk down), the fight was unusable: the band art was clipped to a sliver, and the move tray at the bottom was shoved clean off the screen behind the browser bar, so there was no way to take a turn. The battle filled
fixed inset-0, which on iOS Safari sizes to the chrome-hidden viewport, so its bottom edge sat behind the URL and tool bars. It now sizes to the visible viewport (100svh) so the whole board, tray included, stays on screen, the page paints full-bleed under the notch and home indicator with the battle chrome padded back out viaenv(safe-area-inset-*), and each bandmate cutout scales with the visible height (down to a floor) instead of holding a fixed 124px and clipping. The start-of-battle synergy banners got the same viewport fix so they stop centering up behind the browser bar. Files:app/layout.tsx(viewport+viewportFit: cover),app/_components/battle/battle.tsx,app/_components/battle/battle-top-bar.tsx,app/_components/battle/character-sticker.tsx,app/_components/battle/synergy-reveal.tsx. -
From playtester feedback: you can get back to the post-battle lineup after checking out an artist. When a set ends, "who you faced" lists every artist from the other band, and tapping one opens their profile. But the only way back was the home link, which dropped you on the title screen and lost the lineup, so you couldn't go meet the other four. The profile's back link now reads "← Back to results" when you arrived from a set and returns you to that exact "set closed" screen, same band, same foe, same record, ready to tap the next artist. (The result screen is transient client state with no route of its own, so it's stashed on the way out and replayed on return.) Files:
app/_components/game-shell.tsx(snapshot + restore on?result=1),app/_components/result-screen.tsx(artist links carryfrom=result),app/p/[slug]/page.tsx(conditional back link). -
From playtester feedback: the notification settings stopped colliding on a phone. In Settings, the "What we notify you about" rows put each notification's name and description next to its channel toggles (Browser push, Mobile app, Email). On a narrow screen the long "Mobile app (coming soon)" chip refused to shrink, so the description text got crushed to one word per line and the chips drew right on top of the heading. The rows now wrap: the name and description take the full width, and the channel chips drop onto their own line beneath them (still side by side on a wide screen). Files:
app/settings/notification-settings.tsx. -
The account page sends you back to the main menu in one tap. When you're signed in, the top of the Backstage Pass screen showed a ☰ Menu button that opened the slide-out nav, an extra hop just to get home. It's now a "← Main menu" button that drops you straight on the title screen. Files:
app/account/page.tsx. -
From playtester feedback: button and combat sounds fire the instant you tap. Even after the clips were cached, there was a beat of lag before a press made a sound, worst on phones. The clips played through an
<audio>element, which spins up the media-playback pipeline on every hit, and mobile browsers skipped the on-mount preload entirely (they ignore<audio>preloading until you've interacted), so the first tap on a phone paid for the download, the decode, and the spin-up all at once. Every one-shot sound (the button tick, the back chirp, the mode stingers, the combat move stings) now plays through the Web Audio engine: each clip decodes into a buffer once and fires sample-immediately, and the buffers genuinely warm on first paint because the fetch isn't gated behind a tap. Rapid presses overlap on their own, so the old pool of four tick elements is gone. Anything Web Audio can't decode falls back to the old<audio>path, so nothing regresses. Files:lib/audio/sfx.ts(new shared engine:playSfx,warmSfx),lib/ui-sound.ts,app/_components/button-press-sound.tsx,app/_components/mode-stinger.tsx,app/_components/battle/combat-sfx.ts. -
When your head-to-head opponent walks off, you're told instead of left hanging. From playtester feedback: if one player quit a live two-player match (or just closed the tab), the other was stranded on "Foe is choosing…" forever, waiting on a move that was never coming. Quitting now tells the server you've left, and closing the tab fires the same signal on its way out, so your opponent's screen ends the set cleanly: "Your opponent walked off. You take the set." In Jam Session that forfeit settles the match rating like any other result. (A hard crash with no chance to send the signal still can't be caught here; that needs the seat-reservation grace window noted in
docs/realtime.md.) Files:lib/realtime/types.ts(Leaveintent),lib/realtime/server.ts(forfeit-on-leave + MMR settle),lib/realtime/client.ts(leaveBeacon),app/_components/battle/pvp-battle.tsx(send Leave on quit/pagehide, forfeit banner). -
The band cutouts line up with each other now. On the battle stage every bandmate is a peel-sticker cutout, but the source photos were shot at wildly different framings: a full-body guitarist, a tight drum-kit crop, a singer lost in a wide landscape frame. So they floated at mismatched sizes and heights, some heads near the top of their slot, some shrunk down in the middle (the screenshot that kicked this off had five members at five different scales). Each cutout is now re-centered on a common square: find the subject's silhouette, scale it to a uniform height, center it. The lineup reads as one even row of figures instead of a scattered collage. Same art, just squared up, and the heaviest file dropped from 5.5 MB to about 600 KB along the way. The nine legacy cutouts in
public/cutouts/*.pngwere reprocessed to 900x900 with the subject's bounding box centered, matching the iOS minter'sfitInSquareCanvasframing so freshly minted roster art lands the same way. -
The two-player fight now turns sideways too. Head-to-head matches caught up to the AI battle's landscape redesign: turn the phone and your band and your opponent's band face off as peel-sticker clusters, your side left, theirs right, each cutout dominant with its own HP and BPM arcs. The action lives in the same bottom tray, tap a bandmate to raise it, pick Basic or Special, then aim at a target and Confirm (which shows that target's own HP and BPM before you commit). Held in portrait, the screen asks you to rotate before the set starts. Same realtime turns, same who-goes-when rules, just the wider stage. Files:
app/_components/battle/pvp-battle.tsxnow wires the redesign pieces (rotate-to-battle.tsx,battle-top-bar.tsx,character-sticker.tsx,battle-tray.tsx). -
The tutorial's practice fights caught up to the new battle stage. Maestro Magnus's four onboarding battles were still drawn the old way: a portrait two-column grid of bordered fighter cards with little HP bars and BPM pips, while the rest of the game had moved to the landscape redesign. So the first fight a new manager ever saw looked nothing like the fights that followed. The tutorial battles now use the same pieces as the real game: turn the phone and your bandmates and the challenger face off as peel-sticker cutouts, your side left, theirs right, each with its own HP and BPM arcs, and the same bottom tray where you tap a bandmate, pick Basic or Special, then aim and Confirm (which shows the target's own HP and BPM first). Crit and miss flourishes play here now too. Magnus now tells you to turn the phone sideways before that first show, so the rotate is a coached beat instead of a surprise, and the tutorial stage fits a real phone the same way the live battle does (sized to the visible viewport so the move tray never hides behind the browser bars, safe-area padding for the notch and home indicator, and on a short landscape phone the combat log and top bar step aside while you act). Same scripted opponents, same stage-by-stage lessons, just rendered on the real stage so what you learn matches what you'll play. Files:
app/tutorial/page.tsx(battle render now wiresrotate-to-battle.tsx,battle-top-bar.tsx,character-sticker.tsx,battle-tray.tsx,gauges.tsx, and mirrors the real battle's phone-fit; the old portraitFighterTile/HpBar/SpPips/MoveChooserpieces are gone),lib/tutorial.ts(the rotate coach line). -
The battle screen turned sideways and went widescreen. The fight is now a landscape stage: turn the phone and the two bands face off as peel-sticker clusters, your side on the left, the foe on the right, each cutout dominant with its own HP and BPM arcs. The action moved to a single bottom tray, a big circular HP gauge and BPM gauge flanking a center that swaps through the turn (pick a fighter, then Basic / Special, then a Confirm that shows the target's own HP and BPM before you commit), and an X up top backs out of an attack. Held in portrait on a phone, the screen now asks you to rotate before the set starts. Same combat, same numbers, more room to read the board. Files:
app/_components/battle/battle.tsxwiring in the redesign pieces (rotate-to-battle.tsx,battle-top-bar.tsx,character-sticker.tsx,battle-tray.tsx,gauges.tsx). -
From playtester feedback: you can tell buttons from labels now. The drop shadow means "you can press this." The zine look puts a hard offset ink shadow on a lot of things, and it was on everything: buttons, info panels, stat readouts, and plain chips alike, so nothing looked more tappable than anything else. Now the shadow is reserved for things you can actually press. Buttons keep the raised shadow and physically press in when you tap them (they slide onto their shadow and it collapses, like a stamp pushed into the page); static surfaces (the band/synergy strip, HP and stat readouts, non-clickable chips, content cards, profile and form panels) sit flat on the paper with just their ink outline. The press is automatic for every button across the app; the flat-vs-raised choice is recorded as a house rule in
docs/visual-system.mdwith reusable tokens inlib/zine.ts. Touches ~45 files; the press lives inapp/globals.css. -
The main menu and the slide-out nav got recomposed to match the new design. The home screen's button stack is now Play, Catalog, Build a Band, and Settings, with the always-on Friends (bottom-left) and Feedback (bottom-right) tabs holding the corners. Build a Band drops you straight into the band builder from the title screen instead of routing through the play sub-menu, and Catalog is one tap from home. The slide-out menu (the ☰ on any header) was reordered and relabeled to Exit to main menu, Battles, Rules, Settings, Tutorial, Account, Admin, and Credits, with a new Credits page (
/credits) crediting the bands and the crew. "Exit to main menu" resets the game to the title screen even when you're already on it. Files:app/_components/title-screen.tsx,app/_components/global-nav.tsx,app/_components/game-shell.tsx,app/credits/page.tsx,content/credits.md. -
From playtester feedback: the genre chip on the battle band header now shows the bonus, not a genre name. Next to each band's name during a fight (and on the opponent-pick cards) sat a chip like "⭐ PUNK" or "⭐ ALTERNATIVE." That's the band's active synergy, a band-wide buff that fires when three or more bandmates share a genre, but because it was labelled with the genre name it read like a genre tag stuck on the band (genres belong to characters, not bands). The chip now names the buff it grants instead: "⭐ −1 BPM COST," "⭐ +15% DMG," "⭐ +25% MAX HP," and so on, with the full synergy effect still on hover. Same gameplay signal, no more genre-on-a-band confusion. Files:
lib/game.ts(SYNERGY_INFOgains ashortbonus label),app/_components/battle/synergy-strip.tsx,app/_components/opponent-select.tsx. -
The band builder no longer runs off the side of the screen on a phone. On narrow viewports the "Build your band" screen overflowed sideways: the role tabs (Wildcard / DJ) were clipped, genre headers ("5 acts") and the right-most roster cards got cut off past the edge, and the page scrolled horizontally. The main column was forcing the layout wider than the phone because a grid column defaults to its content's minimum width, and the roster's 280px-minimum cards set that floor above the screen width. The column now shrinks to fit, so everything stays on screen. The shared in-flow header (Menu / step / Main menu / Rules) also wraps to a second line instead of pushing "Rules" off the edge. Files:
app/_components/team-builder.tsx,app/_components/header.tsx. -
From playtester feedback: the band builder's bottom tray stopped showing a phantom empty slot, and the Lock-it-in button stopped fighting the Friends and Feedback tabs. With a DJ plus three musicians the band reads "5/5" (a DJ holds two slots), but the tray still drew an open "Slot 5" you couldn't fill, since the rest of the roster was greyed out. The tray was counting band members instead of field slots; it now expands a DJ into its two slots the way the desktop lineup already did, so a full band shows five filled cells and the DJ's second slot reads "DJ, 2 slots." Separately, the always-on Friends (bottom-left) and Feedback (bottom-right) tabs were sitting on top of the "Lock it in" button on a phone; the tray now reserves a footer band below the button so those tabs dock under it instead of over it. File:
app/_components/team-builder.tsx(MobileBandTray). -
Signing in now drops you on the battle menu, not the account page. From playtester feedback: tapping "Play as guest" (and signing in or making an account) left you stranded on the account page instead of taking you into the game. The redirect was racing against a page refresh that cancelled it. It now does a clean load straight to the battle menu (or wherever you were headed, if you got bounced to sign in from somewhere). Files:
app/account/page.tsx. -
From playtester feedback: playing as a guest, Practice had no genres to pick. A brand-new guest signs in with a starter catalog (the people you "start" collecting) but no home region yet, and that exact combination slipped past the practice-pool fallbacks. With a catalog but no region, the pool was built only from co-billing adjacency, and a guest's only adjacency is to the venue-only model bands (hollowstem, opening-day) that have no canon battle lineup. Those got filtered out, the pool came back empty, and the genre picker showed "No bands loaded yet. Try again in a moment." Now, whenever the scoped pool resolves to zero battleable bands, Practice falls back to the full canon roster instead of stranding you, the same way it already did for a truly first-run account. File:
lib/regions/practice-pool.ts(loadPracticePool). -
A guest now starts in the Indianapolis scene. Follow-up to that Practice fix: instead of leaning on the full-canon fallback, a fresh account now adopts Indianapolis as its home region (the starter band and catalog are all Indy acts), so Practice and the Catalog are scoped to the Indy scene from the first load. The seeded home region is stamped as already-seen so it doesn't pop the "you discovered Indianapolis" unlock animation for a place you never went. Files:
app/api/player/seed-starter/route.ts,lib/regions/server.ts(markRegionAnimationSeen). -
The battle screen's top stops fighting itself: one header row, and the music button moved out of the way. Two things at the top of a fight were stepping on each other. The band-name strip and the synergy strip were separate rows that both printed the opponent's name, so the name showed twice and ate a row of height; they're now one row that names each side, marks whose turn it is, and shows the active synergies together. And the "Tap to play battle music" toggle was a floating pill pinned to the top-center on a phone, landing right on top of the centered status banner ("Pick a fighter"); it's now a chip in the top bar (icon-only on a phone, full label on wider screens) so it never overlaps. The standalone
/demobattle also stopped showing the global nav, feedback, and friends buttons on top of its own corner controls (it was mounting the battle without the wrapper that hides them in real play). Files:app/_components/battle/synergy-strip.tsx(band name + turn highlight folded in),app/_components/battle/battle.tsx+app/_components/battle/pvp-battle.tsx(music chip in the top bar, droppedBandLabel),app/_components/battle/battle-audio.tsx(inline chip),app/demo/page.tsx(gamePhaseflag). -
The "Tap to play battle music" button plays right away now. Tapping to unmute sometimes started the music and sometimes did nothing until you tapped a second time. The browser only lets the audio engine wake up inside the actual tap, and we were waking it a beat too late (in a render-time effect instead of the tap handler), so on the first tap the engine stayed asleep and the second tap is what really started it. The tap now wakes the engine synchronously, the moment your finger lands, so one tap plays. Files:
lib/audio/use-gapless-loop.ts(prime),app/_components/battle/battle-audio.tsx. -
Button sounds stop pinging the server on every press. The UI sounds (the hi-hat tick on every button, the back chirp, the mode stingers) are served straight from
/public, which on our host meant the browser fired a conditional request and got a "304 Not Modified" on every single playback: a network round-trip for a sound it already had. Those files never change under the same name, so they now ship with a one-year immutable cache header, and the app warms all of them into the browser cache on first paint. After the first load a button press plays from disk with no network traffic at all. Files:next.config.ts(immutableCache-Controlon/sounds/*),lib/ui-sound.ts(preloadUiSounds),app/_components/button-press-sound.tsx(warm on mount). -
Fixed broken cutouts on nopeople's catalog cards. A few of nopeople's show cards (Brant, Dan, Dax, Lee) were rendering as broken-image placeholders. The cause: their cutout was stored as a full
https://battleofthebands.app/cutouts/x.pngURL instead of the relative/cutouts/x.pngthe rest of the roster uses, and the catalog's image optimizer can't process an absolute URL on a host it hasn't been told about (it returned a 400, so you got the little torn-photo glyph). The catalog now collapses any cutout URL on our own domain back to a same-origin path before it reaches the optimizer, so those cards show the real photo again. Members with no cutout (Andrew) keep their instrument emoji as before. Files:lib/cutouts.ts(localizeAssetUrl),lib/catalog/server.ts. -
Jam Session stops dropping you into empty rooms. Hitting the green room used to "match" you the instant anyone else had a queue entry, including players who had long since closed the tab, so you'd land in a room that never started a fight. Two changes fix it. The matchmaker now skips anyone whose green room has gone quiet (their client stopped checking in), and every pairing opens a 25-second ready check: both players confirm "I'm ready" before the battle loads. If your opponent never locks in, they're dropped and you go back into the queue with a quick "your opponent didn't lock in" note; if you're the one who wandered off, the check expires and sends you home. An instant match now means a real, present opponent. Files:
lib/db/schema.ts+lib/db/migrations/0041_jam_ready_check.sql(queueready/matched_at/last_seen_at),lib/matchmaking/server.ts(ready-check pairing,markReady,resolveReadyCheck, the staleness filter, the liveness heartbeat),app/api/queue/ready/route.ts(new),app/_components/queue-provider.tsx(the ready-check modal + requeue/expiry notices),app/jam/page.tsx. -
The /join celebration screens read in our voice now, and the payoff line glows green. The "put your band in the game" entry screen and the "your band is in the game" post-submit screen got a copy pass in the Pitchfork-critic voice the rest of the app is moving toward (no em dashes, no fortune-cookie turns of phrase): the pitch now reads "fans draft real local acts into their rosters and battle with them, your songs scoring every round," and the page intro no longer repeats it word-for-word. The closing line on each celebration ("the game.") now renders in the harmony green from the class palette so the win lands with a splash of color. Voice guidance for the whole app is written up in
docs/writing-style.md("Voice: write like a music critic"). Files:app/_components/join-form.tsx,app/join/page.tsx,docs/writing-style.md. -
After a Jam battle, you can see whether your opponent is from your scene. The result banner now adds a line like "Your opponent collects from Indianapolis venues," drawn from the regions that player actually collects cards from. It lands on the post-battle screen (where you sit for a moment) rather than the matchmaking green room (which flips into the fight in a second), and it only shows for real Jam opponents. The point is the same as the rest of the venue social layer: turn an anonymous match into "oh, this person goes to my rooms." Files:
lib/venues/server.ts(getCollectionRegionsForUser),lib/matchmaking/server.ts(getOpponentSceneForRoom, authorized by room membership),app/api/jam/opponent-scene/route.ts(new),app/_components/battle/pvp-battle.tsx(the end-banner line). -
The venue system grows up: browse by venue, region pages, venue badges, and social proof everywhere. The follow-up to venue pages. The Catalog has a new "By Venue" tab alongside Chronological / By Class / By Band / By Genre: one section per room you've collected from, newest shows first, each header linking straight to that venue's page. Every region now has its own page at
/region/[slug](start with/region/indianapolis): the venues in that scene, which ones have a show coming up, how many cards you've collected there, and the venues you've checked into (a custom illustrated map of the city is wired up as the hero and drops in when the art is ready; until then it's a clean list). Checking in earns badges (first check-in, check-in streaks, and "collect a card from every venue in the region"), and the first time you ever check in to a venue you unlock a random card you hadn't seen from that room's history, so going somewhere new always gives you something. Social proof threads through the app: a venue shows how many people checked in this month, a band page shows how many players collect it, and your Friends page now has a "Where friends have been" feed ("Riley checked in at Hi-Fi"). Still to come in this system: RSVP/"I'm going" for specific shows (tracked separately). Files:lib/db/schema.ts+lib/db/migrations/0040_region_map_image.sql(regions.map_image_url),lib/catalog/server.ts+app/catalog/_data.ts+app/catalog/_components/catalog.tsx(By Venue),lib/regions/server.ts+app/region/[slug]/**(region pages + map),lib/venues/badges.ts+app/api/venues/[id]/check-in/route.ts+app/v/[slug]/_components/CheckInButton.tsx(badges + card unlock),lib/bands/server.ts+app/b/[slug]/page.tsx(collector count),lib/venues/server.ts+app/friends/page.tsx(friend check-in feed),app/v/[slug]/page.tsx,lib/db/seeds/regions.ts+lib/db/seed.ts(fixtures). -
Venue pages for the rooms our model bands actually play. Added The 808, Be Here Now, The Melody Inn, Atomic Bowl, and Orbit Room as real venues with their own
/v/<slug>pages, alongside the existing rooms. Luana and the Iguanas' shows now point at The 808 (page links and all), not the old placeholder rooms. The canon-show seed now resolves each event's venue link by name on every run, so moving a band to a different room repoints the venue page link too (it used to keep pointing at the old room). Files:lib/db/seeds/venues.ts(new venues),lib/db/seeds/canon-roster.ts(venue-link resolution). -
Catalog cards now show the real photo cutouts instead of emoji. When a show card has a real-photo cutout in the database, the Catalog renders it in place of the genre emoji. Revealed cards only; undiscovered cards keep their silhouette so identity stays hidden until you collect them. Also fixed Luana and the Iguanas turning up at the wrong rooms: their shows now live at The 808 where they belong, and the canon-show seed keeps an already-seeded venue in sync when the fixture changes (it used to set venue only on first insert, so edits never reached the database). Files:
app/catalog/_components/catalog.tsx+app/catalog/_data.ts(cutout rendering),content/shows.ts(venues),lib/db/seeds/canon-roster.ts(venue re-sync). -
The venue system grows up: browse by venue, region pages, venue badges, and social proof everywhere. The follow-up to venue pages. The Catalog has a new "By Venue" tab alongside Chronological / By Class / By Band / By Genre: one section per room you've collected from, newest shows first, each header linking straight to that venue's page. Every region now has its own page at
/region/[slug](start with/region/indianapolis): the venues in that scene, which ones have a show coming up, how many cards you've collected there, and the venues you've checked into (a custom illustrated map of the city is wired up as the hero and drops in when the art is ready; until then it's a clean list). Checking in earns badges (first check-in, check-in streaks, and "collect a card from every venue in the region"), and the first time you ever check in to a venue you unlock a random card you hadn't seen from that room's history, so going somewhere new always gives you something. Social proof threads through the app: a venue shows how many people checked in this month, a band page shows how many players collect it, and your Friends page now has a "Where friends have been" feed ("Riley checked in at Hi-Fi"). Still to come in this system: the jam green-room "your opponent collects from these venues" signal, and RSVP/"I'm going" for specific shows (tracked separately). Files:lib/db/schema.ts+lib/db/migrations/0040_region_map_image.sql(regions.map_image_url),lib/catalog/server.ts+app/catalog/_data.ts+app/catalog/_components/catalog.tsx(By Venue),lib/regions/server.ts+app/region/[slug]/**(region pages + map),lib/venues/badges.ts+app/api/venues/[id]/check-in/route.ts+app/v/[slug]/_components/CheckInButton.tsx(badges + card unlock),lib/bands/server.ts+app/b/[slug]/page.tsx(collector count),lib/venues/server.ts+app/friends/page.tsx(friend check-in feed),app/v/[slug]/page.tsx,lib/db/seeds/regions.ts+lib/db/seed.ts(fixtures). -
Undiscovered catalog cards are quieter now. The adjacency engine surfaces a "rumor" card for every unseen slot in a band you've brushed up against, so a wall of solid charcoal silhouettes was crowding out the real ones. Those rumor cards now render as a light dashed-outline placeholder that recedes into the paper, instead of a heavy charcoal panel. Cards you've actually spotted (real date and venue, the "you know where, not who" signal) keep their charcoal silhouette, and collected cards are unchanged. Files:
app/catalog/_components/catalog.tsx. -
Venues are a real thing now, with their own pages and check-ins. A venue used to be just a name typed onto a show. Now each room (The Vogue, HI-FI, Healer, Pioneer, Black Circle to start) has its own page at
/v/[slug]: who's playing there next, past shows that link through to the bands, how many bands have played the room and how many cards exist from its stage, and a Check in button. Checking in is one tap (once per venue per day) and shows live social proof ("X players have checked in here"). When you're signed in, the page also tells you how many cards from that venue you've collected. Show rows on a band's page now link the venue name through to its page. Admins get a Venues section in the backstage to create and edit venues (region, address, map position, links, photo). This is the first slice of a larger venue system (browse-by-venue, region maps, and venue badges are still to come). Files:lib/db/schema.ts+lib/db/migrations/0039_venues.sql(newvenues+venue_check_instables,events.venue_id),lib/db/seeds/venues.ts(Indy venues + backfill),lib/venues/server.ts,app/v/[slug]/**,app/api/venues/**,app/admin/venues/**,lib/admin/api.ts+types.ts,lib/db/validators.ts,lib/bands/server.ts+app/b/[slug]/page.tsx(venue links). -
Marketing email system: templates, campaigns, audience segments, and opt-in controls, all MCP-driven. Four new DB tables back a system where an AI agent composes, previews, segments, and sends promotional campaigns through the botb-admin-mcp server — no admin UI needed. Audience segments include all opted-in users, active users, new users, by home region, and admins. Settings page gains an Email section with a marketing opt-in toggle, separate from the existing notification grid. Batching stays within Resend free tier limits. Files:
lib/marketing/(four modules),lib/db/schema.ts+lib/db/migrations/0038_marketing_email.sql,app/api/mcp/[transport]/route.ts(13 new tools),app/settings/email-preferences.tsx(new),app/settings/actions.ts+page.tsx,docs/marketing-email-design.md. -
The first three lifecycle emails ship, plus a one-click unsubscribe that actually works. Three ready-to-send templates, each grounded in a real signal rather than a calendar blast: "the band you battled is playing a real show" (your in-game opponents with upcoming gigs), a weekly "this week near you" regional digest, and a "your friend is playing live" social email that fires when a friend who is a claimed artist lands on a bill. A new
friends_performingaudience segment backs that last one (there's no RSVP table yet, so "a friend is performing" is the strongest social signal we can ground in real data). The unsubscribe link in every marketing email now resolves to a working page that turns marketing emails off (it used to 404), so campaigns are safe to actually send. The templates and three draft campaigns seed in for local iteration. Files:lib/marketing/templates-content.ts(new),lib/marketing/segments.ts(friends_performing),app/api/marketing/unsubscribe/route.ts(new),lib/db/seed.ts. -
You have a friends list now: add by code, see who's online, and challenge them to a battle. A "Friends" button sits in the bottom-left corner everywhere in the app (it tucks away mid-battle). Tapping it opens a panel split into Online and Offline, sorted by name. Each friend has a Challenge button: pick which of your bands to bring and it sends them a challenge. When a friend challenges you, the Friends button shows a badge and the challenge waits in the panel until you Accept (which drops both of you straight into the battle) or decline. To add someone, open "Add a friend" for your own code and QR (tap to copy, or let them scan), or type in theirs. This replaces the old /friends page, which only listed people you had already battled: friends are now an explicit, mutual list you build, and /friends is the full-screen version of the same panel. Online status is coarse on purpose (you show as online if you have used the app in the last few minutes). Files:
lib/db/schema.ts+lib/db/migrations/0037_friends_and_challenges.sql(user.friend_code/user.last_seen_at, newfriendships+challengestables),lib/social/server.ts+lib/social/types.ts,app/api/player/social|friends|challenges/**,app/_components/social-provider.tsx+friends-launcher.tsx+friends-ui.tsx,app/friends/page.tsx+friends-view.tsx,app/api/queue/sweep/route.ts(expires stale challenges),lib/db/seed.ts(fixture). -
Band page member photos fixed. The public band page (/b/nopeople etc.) showed emoji instead of the real cutout photos for band members from nopeople and Luana and the Iguanas. The page was reading
people.imageUrlwhich those seed scripts never set (onlycharacterVariants.imageUrlwas populated). It now falls back to the cutout map so members show their real faces. File:lib/bands/server.ts. -
Signing in and out works on every domain, not just one. Signing in or out from
battleofthebands.apporwww.battleofthebands.appfailed with a 403 "Invalid origin" error: the auth layer only trusted a single canonical host, so the apex and www (and Vercel preview deploys) were rejected. All of the hosts the game serves on are now trusted, so sign-in and sign-out work everywhere. Files:lib/auth/server.ts(trustedOrigins). -
After a show, the band you faced deals onto the result screen as five tappable cards. When a battle ends, the opponent's five members fan into the center of the result screen ("You faced The Garage Gremlins" → "Tap a card to meet the artist"), each card dealt in with a short stagger. Tap any card to open that musician's public artist profile, where you can hear their tracks and see every band they play in. It is a one-tap path from "I just battled these folks" to the real people behind the characters, which is the whole point of the game. Respects reduced-motion (the cards just appear). Files:
app/_components/result-screen.tsx(the card row, each a/p/[slug]link),app/_components/game-shell.tsx(resolves the opponent's members from the roster and passes them in),app/globals.css(thecard-deal-inkeyframe). -
The Catalog has a "Recently Played Against" tab. A new lens, alongside Chronological / By Class / By Band / By Genre, that lists the cards from the bands you've battled, most recent first (up to 50), so the opponents you just fought are one tap from their catalog page instead of buried in the grid. It deliberately spans every region (you fought them wherever you fought them) and shows one card per opponent: their latest show, preferring a card you've already revealed. To make this possible, each battle now records the opposing lineup (it was only keeping the opponent's band name before), the same way it already records your own. Files:
lib/db/schema.ts+lib/db/migrations/0036_battle_opponent_members.sql(newbattle_records.opponent_member_ids),lib/player-data/server.ts,lib/bands-store.ts,app/api/player/battles/route.ts,app/_components/game-shell.tsx+app/play/[roomId]/page.tsx(record the opponent lineup),app/catalog/_components/catalog.tsx(the tab),lib/db/seed.ts(fixture). -
Practice drops you straight into the fight. Picking a genre in Person vs AI used to land on an opponent-review screen (a preview of the band you were about to face, with "Take the stage" and "Draw another" buttons) before the battle. That extra tap is gone: choose a genre and a random band of that genre walks on stage and the match begins. After a battle, "Rematch" returns you to the genre picker, where picking again draws a fresh opponent and starts the next fight. Files:
app/_components/game-shell.tsx,app/_components/header.tsx,app/_components/practice-genre.tsx,app/_components/practice-intro.tsx(removed). -
You can back out of the play flow without reaching for the menu. The band picker and the team builder were the two screens in the Play sequence with no back button, so the only way out was the header's Menu / Main menu. Both now have a "← Back" button: the band picker steps back to the Play menu (Practice / Jam Session / On Tour), the team builder steps back to the band picker, each with the same back sound the other steps already use. The Play menu's existing "← Main menu" button now plays that back sound too. Files:
app/_components/band-picker.tsx,app/_components/team-builder.tsx,app/_components/play-menu.tsx,app/_components/game-shell.tsx. -
Catalog buttons click again. The catalog screen used to opt out of the app-wide button tick entirely, so it was the one silent screen. Its buttons now play the same hi-hat tick as the rest of the app. The back button is the one exception: it keeps its own distinct back sound. Files:
app/_components/button-press-sound.tsx. -
The band manager page shows your private link and makes clear it's not the /join sign-up link. Bands sign up at
/join, but the page they actually manage from lives at a private/intake/...link, and revisiting/joinjust spins up a duplicate band. The manager page now opens with a "Your private manage link" card: the full URL, a one-tap Copy link button, and plain copy telling the band to bookmark it, that everything saves as they go (each tab has its own Save, no final submit), that it's different from /join, and that anyone with it can manage the band. Files:app/intake/[appearanceToken]/_components/ManageHub.tsx. -
The Roster tab is an editable lineup now, and saving can't wipe your band. It used to be an add-only form that always started blank, so after saving it looked empty (members only showed under Links/Photos), and because saving the roster is replace-all, a band coming back to add one more member by filling a single row would have deleted everyone else. The tab now pre-fills with your current lineup as editable cards: fix anyone's name, instrument, or role in place, add or remove members, then one "Save roster" saves the whole lineup safely. Changing a member's instrument updates them cleanly instead of creating a duplicate. The submit button and confirmation also no longer say "Send to photographer" when per-member photos are on (it reads "Save roster" and points to the Photos tab). Files:
app/intake/[appearanceToken]/page.tsx+lib/intake/auth.ts(load the appearance's members with instrument/slot),app/intake/[appearanceToken]/_components/IntakeForm.tsx(pre-filled editable rows + copy),app/intake/[appearanceToken]/_components/ManageHub.tsx,lib/intake/roster-submit.ts(replace-all deletes by variant id so an instrument change doesn't duplicate a member). -
Your bandmates show their real faces in the band picker now. The band cards in the band picker and the lineup chips on the "you formed a band" screen used to show each member's emoji. They now render the member's real photo cutout when we have one, falling back to the emoji for anyone we haven't shot yet, the same art the battle stage already uses. Files:
app/_components/band-picker.tsx,app/_components/band-formed.tsx,lib/cutouts.ts. -
There's a no-setup demo battle at
/demo. A playable showcase match (nopeople vs Luana and the Iguanas) you can drop someone straight into, no account or tutorial needed; when the fight ends it restarts on its own. Built for showing the game off from a single link. Files:app/demo/page.tsx(new). -
The audio encoder now loads from our own site, not a third-party CDN, so uploads are reliable. Follow-up to the "Encoding 0%" fix: the root cause was the ~31 MB ffmpeg.wasm core being fetched from unpkg on first use. When that fetch stalled or was blocked, the encode hung or failed, and Sentry's noise filter was silently dropping the resulting "Failed to fetch" reports. We now serve the core from our own origin (
/ffmpeg/..., copied from a pinned@ffmpeg/coredependency at build time, so it's CDN-cached and never depends on unpkg), and the SentrybeforeSendfilter no longer drops errors tagged with afeature(so a real audio-load failure is reported instead of swallowed). Files:scripts/copy-ffmpeg-core.mjs(new),package.json(@ffmpeg/coredep + copy step on dev/build),lib/audio/compress-client.ts(same-origin core),instrumentation-client.ts(keep tagged feature errors),.gitignore(/public/ffmpeg/). -
Audio uploads can't get stuck on "Encoding 0%" anymore, and a failed encode no longer blocks the upload. From a real band-intake session: an upload hung forever at "Encoding 0%". The browser-side Opus encoder kept a single ffmpeg.wasm worker in module memory and never tore it down on failure, so once an encode crashed the worker, every retry reused the dead instance and hung with no progress and no error. Now the encoder terminates and rebuilds the worker on any failure, wraps both the WASM load and the encode in timeouts (so a hang becomes a real error instead of an infinite spinner), reports failures to Sentry with a tail of the ffmpeg log (this path used to be invisible to both Sentry and Honeycomb), and treats empty output (a track shorter than the 60s trim window) as a failure. Both the band-intake and admin uploaders now fall back to uploading the original file when the encode fails, so a broken encoder never dead-ends a band. Files:
lib/audio/compress-client.ts,app/intake/[appearanceToken]/_components/BandAudioSection.tsx,app/admin/bands/_components/BandAudioSection.tsx. -
Bands can add themselves to the game from one link. New
/joinpage: a band types its name and lands straight in its own intake hub to fill in lineup, links, and songs. No show required, so a booker can forward one onboarding link to every band on their roster and each one self-serves. Under the hood a hidden placeholder appearance (under a synthetic "no show yet" container) carries the band's intake token until they have a real gig; that container is excluded from the public band page so it never shows as a fake show. Slug collisions get a numeric suffix, and a honeypot field keeps casual bots out. Files:app/join/page.tsx(new),lib/onboarding/server.ts(new),lib/roster/canon.ts(ONBOARDING_EVENT_ID),lib/bands/server.ts(excludes the placeholder container). -
Shows can link out to tickets, and a band's show is one step to set up. A show can now carry a ticket/info link (Eventbrite, the band's own site, etc.). When it does, that show's row on the band page becomes clickable with a "Tickets →" affordance. On the admin side, the in-repo MCP gained a
create_showtool that creates the event and attaches every band playing it (headliner plus co-bills) in a single call, instead of acreate_eventfollowed by onecreate_appearanceper band;create_eventandupdate_eventalso take aticketUrlnow. Files:lib/db/schema.ts+lib/db/migrations/0035_event_ticket_url.sql(newevents.ticket_url),lib/db/validators.ts,app/api/mcp/[transport]/route.ts(create_show, ticket-url on create/update),lib/bands/server.ts+app/b/[slug]/page.tsx(clickable show rows). -
Bands have a public page now, with their upcoming shows. Every band gets a shareable page at
/b/<band>(e.g./b/nopeople): the lineup (each member taps through to their own profile), the band's link-outs (Bandcamp, Spotify, Instagram, website), and, front and center, a list of upcoming shows so a fan or promoter can see where to catch them next, with past shows below. Upcoming dates come straight from the events in the database, so keeping a band's page current is just adding their shows. Bands now carry their own link-outs (a newlinksfield, same as the per-person profiles); canon bands seed theirs. Files:app/b/[slug]/page.tsx(new),lib/bands/server.ts(new),lib/db/schema.ts+lib/db/migrations/0034_band_links.sql(newbands.linkscolumn),lib/db/seeds/canon-roster.ts(seeds canon band links). -
The Catalog is organized by region now, and each scene has its own look. Region used to be one sort tab among many. It's now the top-level frame: a "scene" switcher sits above the catalog (Indianapolis, Chicago, and any others you've discovered), and the Chronological / By Class / By Band / By Genre lenses sort within whichever scene you're standing in. Switching scenes re-skins the whole page in that region's colors: Indianapolis dark blue and red, Chicago flag blue and star red, Seattle/PNW teal, each with its own background texture. And discovering a new region now plays a full-bleed celebration (the screen floods the region's color with comic sunburst rays behind the flyer), the same kind of moment you get when you scan a new character, instead of a small card on a grey backdrop. Files:
app/catalog/_components/catalog.tsx,app/_components/region-discovered-overlay.tsx,lib/regions/palette.ts(new, the per-region two-tone palettes),lib/db/seeds/regions.ts(region accent colors). -
The browser tab has a real icon now. The site was still shipping the default Create Next App favicon (the Next.js triangle). It's now a Battle of the Bands mark: a punk-red guitar pick struck by a yellow battle bolt on cream paper, matching the punk-flyer palette. Ships as a vector favicon for modern browsers, a multi-resolution
.icofor older ones, and a 180px Apple touch icon for home-screen installs. Files:app/icon.svg(new),app/favicon.ico(replaced),app/apple-icon.png(new). -
Battle music loops seamlessly now. Battle audio used a plain
<audio loop>, which leaves an audible gap at the loop point. It now plays through the same Web Audio looping path as the tutorial theme (lib/audio/use-gapless-loop.ts), so a track that was authored to loop does so without a seam. Battle tracks are cross-origin blobs, so the hook falls back to the old<audio loop>automatically if a browser can't fetch+decode one (verified in prod that the blob store allows it, so the gapless path is what actually runs). Files:app/_components/battle/battle-audio.tsx,lib/audio/use-gapless-loop.ts. -
Maestro's tour greets you by name too. The character spotlight (the "▶ Maestro's tour" demo reachable from the catalog, where Maestro Magnus walks you through a new bandmate's moves in a scripted 1v1) used to open with "MANAGER! A new bandmate has stepped into AUX!" and sign off with "Sign them to a band when you're ready, MANAGER." It now uses the name you entered in the tutorial intro, falling back to "MANAGER" when none was captured, so the Professor is consistent wherever he speaks. Files:
lib/tutorial-spotlight.ts,app/tutorial/spotlight/[characterId]/page.tsx. -
The tutorial asks your name now, the Band Professor uses it, and finishing the tutorial offers to save your progress. The intro always asked "First, what is your name?" but never gave you a box to answer, then called you "MANAGER" forever. Now there's a name field at that beat: type a name and Maestro Magnus greets you by it through the rest of the intro, the starter pick, and every new-bandmate reveal (leave it blank and he falls back to "MANAGER", so the flow never blocks). The name is kept in your browser and, if you already have an account (everyone gets an anonymous one on the tutorial), saved to it. The completion screen is the new front door for friends who arrive via a shared
/tutoriallink: guests see "Save your progress →" leading to sign-up with the name pre-filled, which upgrades the anonymous account and keeps your starter band; players who already have a real account just get "Play the full game" as before. Files:lib/tutorial.ts,app/tutorial/page.tsx,app/tutorial/actions.ts(new),lib/manager-name.ts(new),lib/auth/user.ts(new),app/account/page.tsx. -
Landing page says open alpha now, invites you to sign up, and tells bands how to reach us. The apex-domain front door used to say "we're in closed alpha, which is to say it's rough and we kind of love it that way..." which read like a velvet rope. We're open: anyone can make an account and play. Copy now reads "we're in open alpha. Anyone can make an account and play." over the same highlighter swipe, and a new mono line under the "Already in?" sign-in link reads "In a band and want in? play@battleofthebands.app" so artists who want a roster spot have somewhere to land (inbound forwards to the operator inbox via ImprovMX). Files:
app/_components/alpha-landing.tsx. -
The Band Professor demos have a theme song now, and it follows your starter band. Maestro Magnus's tutorial (
/tutorial) and every character spotlight demo (/tutorial/spotlight/<id>) play a looping background track. It starts as soon as you interact with the tutorial (no "tap to play" hoop) and loops seamlessly: rather than an HTML<audio loop>, which leaves an audible gap at the seam, it uses the Web Audio API to restart sample-accurately. When you pick a starter band, the theme switches to that band's arrangement and stays switched across all the tutorials (Luana and the Iguanas and Nopeople each have their own; before you pick, the standalone Magnus theme plays, and the Starchaser theme is reserved for the third starter band once it lands). A small speaker chip in the top center mutes/unmutes, and the choice persists on its own setting separate from battle music. The audio is a set of 96 kbps Opus loops (public/sounds/magnus-theme-*.ogg, ~150-220 KB each), the same codec battle music uses. Files:lib/audio/use-gapless-loop.ts(new, reusable Web Audio loop),app/_components/magnus-theme.tsx,app/tutorial/page.tsx,app/tutorial/spotlight/[characterId]/page.tsx. -
Scanning a sticker minted by a band that came through self-service intake now shows the photo, not the chick emoji. The
/api/character-versions/[id]/imageupload route used to dump the file in Vercel Blob and return the URL, but never wrote that URL back tocharacter_variants.image_url— callers were expected to chain a PATCH, and neither the photographer flow nor the iOS minter did. So any variant without an admin-uploaded portrait fell back to the emoji on the scan page (/s/<id>) and everywhere else that readsimage_url. The upload route now persists the URL itself; existing minted stickers will pick up their real photo the next time the band's portrait is re-minted, or via a one-line admin patch against the variant row. Files:app/api/character-versions/[id]/image/route.ts,lib/blob/README.md(updated docs to reflect the new self-sufficient behavior). -
Practice pool now widens through your catalog, not just your regions. Building on the regions PR: a band joins your Practice pool if it has ever shared a bill (a show in the last 24 months) with any band you've already scanned someone from. So a band in your catalog whose member toured with Crypt Society last year quietly pulls Crypt Society into your Practice pool, even if Crypt Society's home region isn't one you've discovered. Catalog seen is now slug-only on the back end (the old variant-UUID sibling row was dead weight that no consumer read), which makes the co-billing join clean. Files:
lib/db/migrations/0032_catalog_seen_slug_only.sql(deletes the orphan UUID rows, adds a column comment),lib/stickers/server.ts(the writer now records onlypeople.slug),lib/regions/practice-pool.ts(the new co-billing self-join overband_appearances). -
Bands have a region now, and you do too. Each band picks a "Where are you from?" region at intake (a curated list of cultural-nexus scenes: Indianapolis, Chicago, LA, NYC, Montréal, DMV, etc., 23 to start). Every player also gets a home region: auto-set from the first sticker you scan, override anytime in Settings. Practice opponents now reflect the regions you've unlocked. A band shows up in your Practice pool if its home region is one you've discovered, or it ever played a show in a region you've discovered, so a touring LA band that played Indianapolis is reachable for everyone in the Indianapolis scene. New players still see every canon band until they've discovered any region, so first-run never lands on an empty pool. Files:
lib/db/schema.tspluslib/db/migrations/0031_regions.sql(newregionsanduser_discovered_regionstables, plusregion_id/home_region_idcolumns on events, bands, and user),lib/db/seeds/regions.ts(the 23-region starter set),app/api/regions/route.tsandapp/api/practice/pool/route.ts(new),lib/regions/{server,practice-pool}.ts(new),app/admin/{events,bands}/_components/*Form.tsx(region pickers),app/intake/[appearanceToken]/_components/BandProfileSection.tsx(the "Where are you from?" dropdown),app/settings/home-region.tsx(the override picker), andlib/stickers/server.ts(auto-detect on first scan plus adds the band's region to your discovered set). -
iOS home + nav order match the web title screen now: Play is the front door, band-pick comes after. The native home used to be the old "AUX : THE GATHERING" landing with a framed instrument card, "Assemble. Tune up. Rock them.", and Take the stage / Catalog / Past shows as the primary CTAs. The play flow was also inverted: you picked a band, then chose a show type. Replaced with a zine masthead ("Iss. №1 · Aux Edition", italic "Battle of the Bands" wordmark with a pink drop shadow, "JRPG band brawler · Live from your phone") sitting above three equal-weight paper buttons: Play / Friends List / Settings. The play flow is now band-agnostic and first, matching the web: Home → Play → pick a show type (Practice / Book a show / Jam Session / On Tour, soon) → pick a band → mode-specific next step. Creating a band along the way carries the chosen show type through the celebration, so "Take the stage" lands on Practice, the classic GameModeSelect, or Jam Session depending on what you picked at the start. Catalog, Past shows, Tutorial, Rules, My profile, and Scan a QR move out of the primary column into the side menu drawer; the dead FEEDBACK pill is gone. Files:
ios/.../Views/HomeView.swift(rewritten),ios/.../Views/PlayFlow.swift(band-agnosticPlayMenuView, newPlayIntent+PickBandRoute+PlayDestinationdispatcher),ios/.../Views/PickBandView.swift(accepts an intent, dispatches viaPlayDestination),ios/.../Views/TeamBuilderView.swift+ClassicFlow.swift(BandFormed routes by intent). -
iOS has the classic show pipeline now: pick a show type, pick an opponent, get a band-formed celebration. The native app jumped straight from band pick into Practice. Now after picking a band you can "Book a show" and choose AI mode (casual match against a touring band), Multiplayer (mints a room code and opens the native PvP battle as host), or Competitive (same flow as AI, with the consequence wiring tracked on the web side). The opponent select screen ("Tonight's Card") lists the canon touring bands and any of your own saved touring bands. Saving a new band in the team builder now pushes a "Band Formed" celebration with a last-chance rename when the band landed as "Untitled Band", instead of dumping you back to the band picker. Files:
ios/.../Views/ClassicFlow.swift(new),ios/.../Views/PlayFlow.swift,ios/.../Views/HomeView.swift,ios/.../Views/TeamBuilderView.swift. -
iOS PvP battles got the missing UI: synergy strip, hit pops, music, lobby tiles, quit-confirm, and a turn alert. The native PvP battle was missing a stack of UI the web version has had for a while. Added all at once: an above-the-columns synergy strip showing each side's active genre bonuses; floating damage/heal numbers and a hit flash on the fighter being hit (the realtime server was already sending those effects, but the iOS client was dropping them); a battle-music picker that walks both bands' member tracks and loops one for the fight (with a mute chip in the top bar); a "Forfeit the show?" confirm sheet so EXIT can't accidentally end a match mid-fight; a "notify on my turn" bell that haptically taps you when the foe's move resolves and control flips back to you (toggle persists in
UserDefaultsunder the same key the web uses); and a Green Room lobby with You / Foe tiles showing each side's band name and a Ready / Waiting indicator, instead of the bare spinner. Files:ios/.../Views/PvpBattleView.swift,ios/.../Realtime/RealtimeClient.swift. -
iOS PvP battles now count for real. When a native PvP match ended, the iOS app just showed a win/loss overlay and dismissed: your band's W/L tally didn't move, the match never appeared in Past Battles, the opponent's roster didn't go SEEN in the catalog, and the opponent didn't show up in Friends. Now on the phase to "ended" the client mirrors what the web's
handleBattleEndalready does: it bumps the band's wins or losses, posts a multiplayer battle record with the opponent's band, opponent user id, and match duration, and marks the opponent's roster ids SEEN. The realtime wire state also carriesseats,updatedAt, andbattleStartedAtso the duration is the server-authoritative match length, and a reconnect that replays the terminal state won't double-record. Files:ios/.../Realtime/RealtimeClient.swift,ios/.../Models/Models.swift. -
iOS Rules screen now reads live from the same files as the web. The native Rules view used to be a hand-coded SwiftUI re-implementation of the rulebook, with no changelog at all, so it silently drifted whenever
content/rules.mdchanged. It now fetches a new/api/rulesendpoint that serves the rules markdown, the changelog markdown, and the app version, and renders both with a Rules / Changelog tab bar and a "Issue №X" version masthead. Editcontent/rules.mdorCHANGELOG.mdonce and both web and iOS reflect it. Files:app/api/rules/route.ts(new),next.config.ts(file tracing for the new route),ios/.../Views/RulesView.swift. -
iOS profile links got a Kind picker, a 20-link cap, and the On Tour card stops hardcoding "soon". Three small parity fixes. On the iOS /me editor, each link row used to omit the "kind" selector that the web has, so an artist could pick a bandcamp or spotify or instagram label on the web but the iOS app silently shipped the link with no kind. The row now has a Kind picker (bandcamp / spotify / instagram / website / youtube / soundcloud / tiktok / linktree / other) and new rows default to "website", matching the web. The "+ Add" button now hides at the 20-link cap (web's MAX_LINKS), and the On Tour mode card in the Play menu now derives its " · soon" suffix from the disabled flag instead of hardcoding "Ranked · soon" into the kicker, so when On Tour ships the label flips automatically. Files:
ios/.../Views/MyProfileView.swift,ios/.../Views/PlayFlow.swift. -
iOS Past Battles reads like the web now. Each row used to be just a date, a result chip, and the two band names. Now every row shows both lineups as compact emoji + first-name emblems (yours on the left, theirs on the right, with a divider), tap one to open that artist's profile page. Match length lands in the result stripe ("1m 35s") whenever the battle was recorded with a duration, and the screen header carries a running W/L tally so you can read your record at a glance. Files:
ios/.../Views/BattlesView.swift,ios/.../Models/Models.swift(newdurationMsfield onBattleRecord). -
iOS band cards now carry the tour strip, win ratio, member meter, and edit/delete the web has had for a while. Each saved band card on Pick a band gained a win-ratio percentage next to the W/L pills, an "N/5 MEMBERS" sub-label, and a tour strip: a "Send on tour" button when the band is parked, or an "ON TOUR" pill plus "Tour log (N)" and "End tour" links when it is on the road. The "⋯" overflow menu carries Edit and Delete. Edit reopens the band's lineup in the team builder for renaming or swapping members. Delete prompts with "Delete '<name>'? Their record dies with them." Tapping "Tour log (N)" opens a new Tour Log sheet that shows every show the touring band has played from its own POV: a W/L summary across the road, and a card per battle with the challenger band name, lineup, and date. All wired to the existing player-bands API (
PATCH /api/player/bands/[id]for touring and edit,DELETEfor delete). Files:ios/.../Views/PickBandView.swift,ios/.../Views/TourLogView.swift(new),ios/.../Views/TeamBuilderView.swift(now PATCHes when invoked withediting:),ios/.../API/APIClient.swift(patch/deletehelpers). -
iOS Scan matches the web
/scanpage, and a scanned PvP code opens the native room. Two scan fixes. Visually, the native ScanView used to be a plain camera viewport with a system reticle and "Point at a Battle of the Bands QR code" caption. It now wears the same zine masthead as the web (/scanpage): "Scan / Code Entry" kicker over a "Got a Challenge?" italic headline, an intro paragraph, the camera in a paper panel with a 2px ink border and a 3px hard drop shadow, and a "← Back to title" mono link below it. The camera UX itself stays native (AVFoundation QR detector + reticle). Behaviorally, scanning a/play/<roomId>QR used to bounce the joiner out to Safari with the web room URL, which made the just-shipped native PvP battle pointless on iOS. It now shows an inline "Pick the band you'll bring" picker over the camera, then presentsPvpBattleView(joiner = side b) in afullScreenCover, the same handoff JamSession uses on a server-matched room. Sticker/s/<id>QRs keep their existing inline claim flow. Files:ios/.../Views/ScanView.swift. -
iOS Jam Session green room matches the web: rotating quotes, a non-blocking queue, and "Dead air tonight". The queue used to leave when you left the screen, which made it blocking: wander off to Practice or the Catalog and you dropped out and stopped pairing. Queue state now lives at the app scope (a new
JamQueueStore, the iOS counterpart of the webQueueProvider), so you can walk away while waiting and a match still pulls you in. A persistent black banner sits at the bottom of the app while you're in the queue, showing the band you queued with and a "Leave" affordance. The waiting screen also gained the rotating green-room quote card (the same 10 quotes as web, rotating every 4.2 seconds, starting at a random index so two players don't see the same line in sync) and two escape hatches ("Play Practice" / "Check the Catalog") that wander off without leaving the queue. The radio now says "Dead air tonight" when the catalog has nothing cleared for play, matching the web copy. On match, the Jam Session screen auto-presents the PvP cover instead of waiting for a manual "Take the stage" tap (mirrors the web's auto-route onmatched). Files:ios/.../Realtime/JamQueueStore.swift(new),ios/.../Views/JamSessionView.swift(uses the store; quotes, dead-air, auto-present),ios/.../Views/RootView.swift(rendersJamQueueBanner),ios/.../BattleOfTheBandsApp.swift(injects the store). -
The catalog has search now, on both web and iOS, and the iOS catalog finally reads like the web's. Web gained a search box above the sort tabs that filters every grouping (Chronological, By Class, By Band, By Genre) by character name, band, or venue; hidden cards only match band and venue so masked identities don't leak through. iOS got a much bigger overhaul: cards are tappable now and drill into a Show Card detail, a Pokédex-style Character page (OWNED counter, the full list of bands that person sits in with, "rumor has it…" footer), and a saturated Band page (one row per show date) just like the web. The two missing groupings (Chronological and By Genre) are back, the By-Class header carries the class label with a slot sub-line, and there's a footer tip. A floating band-builder FAB pins to the bottom-right while you browse: tap the corner + on a revealed card to draft a member, open the drawer to review your lineup, name it, and Lock it in → to save through
POST /api/player/bands. iOS also adopted the web's locally-synthesized visibility model (fnv1a buckets) so the design preview keeps the same revealed/seen/adjacent variety as the web. Files:app/catalog/_components/catalog.tsx(search box, query state,matchesCardpredicate threaded through every sort view),ios/.../Views/CatalogView.swift(rewritten end to end). -
The iOS team builder finally looks and behaves like the web's. It used to be a vertical list of toggle rows grouped by slot, with no preview, no random fill, no genre grouping, no tap-to-replace, and no way to pull up the type-advantage chart. Reworked top to bottom: a top row with 🎲 Random (rolls a valid five-piece off the roster), 📖 Genres (opens a type-advantage reference sheet), and ✕ Reset; a horizontal class-tab strip (All / Rhythm / Melody / Harmony / Wildcard / DJ) with live count and per-class cap; a preview panel above the grid that shows the tapped portrait, HP, and the BASIC and SPECIAL move rows with their BPM costs; a portrait sticker grid grouped by genre with section headers in each genre's accent color; and a tap-to-replace flow on the lineup column (tap a filled slot to enter replace mode, the filter snaps to that slot's class, the next ADD swaps in instead of appending). Lock it in still POSTs to
/api/player/bands. Files:ios/.../Views/TeamBuilderView.swift(rewritten). -
iOS battles are side-by-side now, like the web. The native battle stacked the two bands vertically (enemy team on top, a VS divider, your team below), so on a phone you scrolled a tall column and the face-off didn't read. Reworked it to match the web mobile view: two columns, your band on the left and the opponent on the right, with a band-name strip labelling each side and highlighting whose turn it is. Each fighter is a compact tile (portrait, name + genre chip, HP bar over a BPM bar, status chips) and the opponent column is mirrored so the bands face each other. Applies to both Practice/AI battles and the live PvP battle. Files:
ios/.../Views/BattleFighterCell.swift(new compact tile, ported from the webFighterStage),Views/BattleView.swift,Views/PvpBattleView.swift. -
New character type: the DJ. A DJ is a wildcard with no role (not Rhythm, Melody, or Harmony) that takes up two adjacent slots in your five-slot band. Instead of its own moves, a DJ is a copycat: at the start of a battle it mirrors the special moves of the two enemies standing directly across from it on the field, and it can spin either one on its turn. It keeps a copied move even after the enemy it learned it from is knocked out. If the field leaves it nothing to copy (a DJ parked directly across from another DJ, or with no enemy overlapping its two slots), it falls back to a plain basic attack called Scratch so it's never dead weight. To pay for that flexibility a DJ has no signature ability of its own, but it carries 200% of a normal character's HP since it is holding down two spots. You can field up to two DJs per band (the team builder has a new DJ tab, and a DJ tile fills two of your five slots). All the usual costs apply: a copied move still costs its normal BPM and obeys every constraint it would on its original owner. Two starter DJs, DJ Echo and DJ Vinyl, are in the roster. Files:
lib/game.ts,lib/battle/ai.ts,app/_components/team-builder.tsx,app/_components/battle/{battle,bottom-panel}.tsx,content/roster.ts,lib/db/schema.ts+ migration0030; mirrored in the iOS app. -
PvP battles have move sounds now, and the Wildcard's Charm finally has a sound at all. The 16 move stings already played in Practice/AI fights, but real-time PvP (Jam Session) battles were silent because the server resolves combat and the clients only render what it streams. The server now sends each move's sound along with its other effects, so both the web and native iOS PvP clients play the right sting in sync with the hit. Separately, Charm (the Wildcard special) shipped without a sound; it now has one: a soft, hypnotic vibraphone chord with a coy upward lilt. A compile-time guard now fails the build if a future move kind is added without a matching sound, so this gap can't recur. Files:
lib/battle/types.ts(sfxeffect),lib/realtime/server.ts,app/_components/battle/{pvp-battle,combat-sfx}.tsx,scripts/generate-combat-sfx.mjs(+charm),public/sounds/moves/charm.ogg, and iOSRealtime/RealtimeClient.swift+Audio/MoveSFX.swift+Views/PvpBattleView.swift+Audio/moves/charm.m4a. -
Solo and duo acts can sign up now. Band intake used to demand at least 3 members and one of every slot (rhythm, melody, harmony), which shut out solo artists like Jack McDivitt and two-piece acts. The roster now accepts anywhere from 1 to 8 members with no per-slot requirement: a single artist is a valid one-member band. (Players still build their 5-piece battle teams from across the whole catalog, so a small band doesn't make anyone unplayable.) Files:
app/intake/[appearanceToken]/_components/IntakeForm.tsx,app/intake/[appearanceToken]/actions.ts,lib/db/validators.ts(rosterSubmitSchema). -
Solo artists are Wildcards, a new class with a new move: Charm. A one-person band is now its own slot and class, the Wildcard, who does it all. Wildcards carry 50% more health than a standard character and bring a signature special move, Charm: pick an enemy and for the next 3 of that enemy's turns it can't attack you, its single-target swings get steered onto a different foe (or fizzle if you're the last one standing). Charm deals no damage and stacks with other debuffs. Wildcard is the fourth team-building slot (max 2 per slot, like the others) and ships on both web and the native iOS app, with full parity in the battle engine, AI, and PvP. Files:
lib/game.ts,lib/battle/{engine,ai}.ts,lib/moves/kind.ts,lib/intake/defaults.ts,lib/db/{schema,validators}.ts+ migration0028_wildcard_charm.sql,app/_components/team-builder.tsx, and the iOSGame/{Game,Engine,AI}.swift+ battle/team views. -
Bands can bring their own brand assets to stickers. The self-service band intake hub (the token link a band manages their show with) gained an "Assets" tab. A band can upload their own logos, graphics, PNGs, SVGs, and fonts, see them in a grid, and delete them. Those assets then flow into the iOS sticker minter, where a band's logos and graphics show up in a tray and can be dragged onto a sticker (resized and positioned), so a sticker can carry the band's real artwork, not just the photo cutout. Uploads are deduped per band, capped at 10 MB, and stored via the same blob layer as audio and grant documents. Files:
lib/db/schema.tspluslib/db/migrations/0027_band_assets.sql(newband_assetstable),lib/blob/index.ts(uploadBandAsset/deleteBandAsset),lib/intake/band-assets.ts(new),app/api/intake/[appearanceToken]/assets/{route,[assetId]/route}.ts(new, token-gated upload/list/delete),app/api/admin/bands/[id]/assets/route.ts(new, admin-bearer list for the minter),app/intake/[appearanceToken]/_components/{BandAssetsSection,ManageHub}.tsx, and the iOS filesStickerAssetPlacement.swift,Networking/{BOTBModels,BOTBClient}.swift,StickerEditorViewModel.swift,StickerExporter.swift,ContentView.swift,AssignCharacterSheet.swift. -
The AI opponent actually attacks now. From playtester feedback: in Practice the enemy band could spend a whole fight buffing and debuffing without ever throwing a punch. The AI picks one action per turn from a priority list, and two branches re-qualified every turn: a damage-buff member would keep re-buffing its own supports round-robin, and a debuff member (mute, Feedback, Detune) would re-cast on the same target forever, so the plain-attack fallback at the bottom of the list never got reached. Now it's attack-first: buffs only land on damage dealers that don't already have a bonus, debuffs only land on a threat that isn't already carrying that debuff, and preemptive shields wait until an ally is genuinely hurt (under 60% HP). Once a special has done its job the band falls through to attacking. Files:
lib/battle/ai.ts. -
Battles have sound now. Every move plays its own signature sting when it lands: a palm-muted power chord for a basic hit, a full strummed chord plus a cymbal crash for an area attack, a soft major arpeggio for a heal, a rising figure for a buff, an amp squeal for Feedback, a sour detuned warble for Detune, a low growl for a Taunt, a muted chug for a Shield, a tinnitus ring for hearing damage, and a crunchy hard-clipped burst for Clipping (16 in all, one per move kind). The sounds are synthesized, not sampled, so they share one coherent palette. They play at a modest volume through the same mobile-safe Opus-in-Ogg path the battle music uses, and you can turn them off under Settings → Sound (on by default; battle music keeps its own in-battle mute chip). Files:
scripts/generate-combat-sfx.mjs(new generator),public/sounds/moves/*.ogg(new),app/_components/battle/combat-sfx.ts(new),app/_components/battle/battle.tsx,app/settings/{page.tsx,sound-settings.tsx}. -
The iOS app got those battle sounds too. Practice and AI/Auto fights in the native app now play the same 16 move stings as the web, with an on/off switch under Settings → Sound. Native iOS can't decode the web's Opus-in-Ogg files, so the same generator now also builds an AAC-in-m4a set bundled with the app and played through
AVAudioPlayer. (PvP battles don't have move SFX yet on either platform; that's a separate change to the realtime protocol.) Files:scripts/generate-combat-sfx.mjs(now emits m4a),ios/.../Audio/MoveSFX.swift(new),ios/.../Audio/moves/*.m4a(new),ios/.../Views/BattleView.swift,ios/.../Views/RootView.swift. -
The tutorial is quiet again, and its Feedback tab moves to the bottom-left. Two onboarding fixes. The app-wide button-click hi-hat now stays silent through the whole
/tutorialflow (the Maestro dialogue advances on nearly every tap, so the tick was relentless). And the Feedback tab, normally pinned bottom-right, tucks into the bottom-left for the tutorial only, since the bottom-right corner is where Maestro Magnus's dialogue tray and its ▼ "continue" cue live. Files:app/tutorial/page.tsx,app/_components/feedback-widget.tsx. -
Native push delivery is wired end-to-end (dormant until APNs credentials are set). The iOS app now requests notification permission on sign-in, registers for APNs, and stores its device token via a new
/api/device-tokensroute (removed on sign-out). The previously-stubbednative-pushadapter is now a real APNs sender: a hand-rolled ES256 provider JWT (cached) over HTTP/2 to Apple, reaping dead tokens. It is env-gated, so with noAPNS_KEY/APNS_KEY_ID/APNS_TEAM_IDset it stays the safe no-op it was, and nothing changes until an Apple Developer APNs Auth Key is provisioned. Once those env vars exist (and a push-enabled provisioning profile / TestFlight build ships), "Your turn in a battle" and "Battle results" reach the phone the way they already reach the browser. Files:app/api/device-tokens/route.ts(new),lib/notifications/adapters/native-push.ts(real sender),ios/.../Notifications/PushManager.swift(new: registration/receipt + AppDelegate),ios/.../{BattleOfTheBandsApp,Views/RootView,Auth/SessionStore}.swift,ios/.../project.yml(plusBattleOfTheBands.entitlements,aps-environment). -
The admin backstage is a comic book now. If the player-facing app is a hand-made zine, the admin panel is its dark DC-style twin. It used to be near-unreadable: white and pale-gray text designed for a dark theme, sitting on the app's cream-paper background, so everything washed out. Now it has its own noir-night ground dusted with Ben-Day dots, inked panels with thick black gutters and hard drop shadows, comic title lettering, a yellow caption box for page blurbs, hero-red action buttons, and a sidebar that lights up the section you're on. High-contrast and legible instead of a squint. Files:
lib/admin/ui.tsx(COMICpalette, restyledCard/GradientHeading/Button, newCaption),app/admin/layout.tsx,app/admin/_components/admin-nav.tsx(new),app/globals.css(.comic-pop,.comic-nav). -
Em dashes are gone from the copy. Swept em dashes out of all player-facing text, including the password-reset and claim-invite emails, push and email notifications, settings, and the admin panel, replacing them with ordinary punctuation. New
docs/writing-style.mdcodifies the rule (and a ban on setup-and-turn aphorisms) so they stay out; linked fromAGENTS.md. -
Jam Session battles are now fully native on iOS, no more web hand-off. The green room used to queue you up and then bounce the actual fight to the web room. Now the matched battle runs in the app: a native realtime client speaks the same SSE protocol as the web (
lib/realtime), so two phones fight server-authoritatively: pick a ready bandmate, a move, and a target on your turn; the server resolves combat and streams the new state to both players (alternating turns, reconnect-on-drop). The fight reuses the same fighter cards and stage as Practice, with a live "your turn / opponent's turn" banner and a win/lose screen; MMR write-back is handled server-side on match end. Files (underios/):Realtime/RealtimeClient.swift(new: SSE stream + intent POST + wire→Combatant mapping),Views/PvpBattleView.swift(new),Views/JamSessionView.swift(matched state now launches the native battle). -
iOS Settings now has notification preferences (parity with web /settings). The app's Settings screen gained a Notifications section with per-type channel toggles ("Your turn in a battle", "Battle results"), matching the web grid and sharing the same stored preferences via a new
/api/notifications/preferencesroute (GET effective state, PUT the full channel map per type). Transactional types stay off the list; the server remains the source of truth for which channels each type allows. Note: the "Phone push" toggle persists a real preference, but native APNs delivery isn't wired into the app yet, it's in place for when it lands. Files:app/api/notifications/preferences/route.ts(new),ios/.../Views/NotificationSettingsView.swift(new),ios/.../Views/RootView.swift(SettingsView). -
The native iOS app reaches feature parity with the web app. A big lift across the SwiftUI client so the app is no longer a thin battle demo:
- Play flow. Tapping a band used to drop you into a fight against a random opponent. Now it mirrors the web: Play menu (Practice · Jam Session · On Tour) → pick a genre → a random band of that genre is revealed (with a "draw another" re-roll) → pick a battle mode (Manual or the new Auto, where the AI drives your band) → battle.
- Battle + victory music. A fighter's track loops during the fight and the winner's track plays you out, with a speaker toggle in the battle bar (mute persists).
- Rules. The previously-dead Rules button opens a native reference (classes, type chart, synergies, moves, status badges) sourced from
content/rules.md. - Team builder. Build a new 5-piece band in-app (slot picker enforcing the 2/2/1 rule, live synergy hint) instead of bouncing to the web — "Build a new band" on the band picker.
- Friends. A native rivals list (everyone you've battled in PvP) with W/L, show count, last band, and last-played time.
- My profile. Claimed artists can edit their bio, stage name, pronouns, and links from the app (backed by a new
/api/meroute). - Catalog. Now the real SEEN / SOLD / HIDDEN scrapbook with adjacency unlock (band-mates of a revealed character light up), backed by a new
/api/catalogroute — not a flat people list. - Tutorial. An RBY-style onboarding: starter pick → staged 1v1 → 2v2 → 3v3 → 5v5 demo battles (driven by the real engine) with dialog teaching classes, basic/special moves, and synergies.
- Jam Session. A native green room — join the matchmaking queue, see who's around, listen to green-room radio while you wait. The realtime PvP battle itself still hands off to the web for now (next phase).
- Files (under
ios/):Views/{PlayFlow,RulesView,TeamBuilderView,FriendsView,MyProfileView,CatalogView,TutorialView,JamSessionView,BattleView,HomeView,PickBandView}.swift,Audio/BattleAudio.swift,Game/Game.swift; plus web routesapp/api/me/route.tsandapp/api/catalog/route.ts.
-
The intake card now reflects the band's chosen Color (and updates on save). From playtester feedback. The content-card accent border was reading the genre accent, not the Color field on the Profile tab — so editing Color appeared to do nothing (two mismatched purples), which also made the save look like it didn't persist (it always did). The card now tints from
band.color, and saving the profile triggers a refresh so the new color shows immediately. Files:app/intake/[appearanceToken]/_components/{ManageHub,BandProfileSection}.tsx. -
The public landing page got loud. The apex-domain front door (
battleofthebands.app) is now a photocopied gig flyer: a paper sheet pinned to the page with a binder clip, a hand-drawn comic explosion over the masthead, a punk-red rubber-stamp kicker, a double-rule masthead, and the signature hard ink shadow on the "Create an account" button (which now wobbles on hover). The copy got a Frank O'Hara rewrite — breezy, present-tense, talking to you like a friend ("the next build is more or less your fault… come hang around"). The global nav puck is suppressed here too — it's the marketing door, not the app. Then it got roughed up to read like an actual hand-made zine instead of a clean poster: tacked up crooked, photocopier toner speckle over the whole sheet, a hand-inked wobbly border and rules (no crisp vector lines), masking tape at the corners, a yellow highlighter swipe under "closed alpha", a misregistered red ghost behind the title, and the "Create an account" button circled in pen. Files:app/_components/alpha-landing.tsx,app/_components/suppress-global-nav.tsx(new),app/_components/global-nav.tsx(data-suppress-nav). -
The zine look leans in: the nav drawer is a notebook page and the Catalog's sort tabs are binder-clipped folders. The slide-out menu now sits on ruled notebook paper (pencil-blue lines + a red margin rule), with each destination jotted in the writing area. The Catalog's Chronological / By Class / By Band / By Genre sort row is now a strip of file-folder tabs hung from the ink rail, each pinched by a little binder clip; the active sort fills with ink and dips into the page. Purely a styling pass on two self-contained surfaces — no behavior change, and reduced-motion users get the static look with no animation. Files:
lib/zine.ts(ruledPaperStyle),app/_components/global-nav.tsx,app/catalog/_components/catalog.tsx,app/globals.css(.zine-tab). -
Intake genre now shows as the card's accent border, not a chip. From playtester feedback — the masthead "Genre: …" badge felt like clutter. Dropped it; the band's genre now tints the content card's border (its per-genre accent color, with a bold top edge) instead. Genre is still set on the Profile tab. Files:
app/intake/[appearanceToken]/page.tsx,app/intake/[appearanceToken]/_components/ManageHub.tsx. -
The Artist Grant page (
/legal/artist-grant) is readable now. From playtester feedback ("this is a little rough"). It was rendering as a flat wall of text — the page leaned on Tailwindproseclasses, but the app has no@tailwindcss/typographyplugin, so they were no-ops (andprose-invertwas a dark-theme variant on the light page besides). Rewrote it in the zine style with an explicit react-markdown component map: a double-rule masthead, a paper card, italic Georgia section headings with underlines, mono uppercase sub-labels, proper bulleted/numbered lists, bold field labels, and dashed section separators. File:app/legal/artist-grant/page.tsx. -
Intake opens on Profile, and the genre is the band's to set (not a "default"). From playtester feedback. The band manager now lands on the Profile tab first (and Profile is leftmost) so a band sets up its identity — name, genre, look — before adding the roster, then links + audio. The masthead chip is relabeled "Default genre" → "Genre": there's no per-member override anymore (one genre per band), and the band picks it themselves on Profile, so "default" was misleading. Files:
app/intake/[appearanceToken]/page.tsx,app/intake/[appearanceToken]/_components/ManageHub.tsx. -
Buttons now click. A short hi-hat tick plays whenever you press a button, app-wide — a little tactile feedback for taps. Two places stay silent on purpose: the Catalog screen (so flipping through cards doesn't rattle) and the Menu sidebar / ☰ trigger (navigation chrome, not gameplay). The sound plays at a low volume and overlaps cleanly on fast presses; browsers that block autoplay simply stay quiet until your first tap. The clip ships as Opus-in-Ogg (~3 KB) and plays through a small pool of native
<audio>elements — the same mobile-safe path battle music uses. Files:app/_components/button-press-sound.tsx(new),app/layout.tsx,app/_components/global-nav.tsx(data-no-button-soundopt-out),public/sounds/button-press.ogg(Hi-Hat Semi-Closed 002 by DanVGC). -
The Catalog is now backed by real shows. Each catalog card is a character's appearance at an actual show (a
character_variantat a realeventsrow), grouped under the person who played it, instead of the hand-faked in-memory show list. Venues, dates, and per-show lineups now come from the database, so the Chronological / By Band / band-detail views reflect real gig data. No change to how cards reveal, draft, or play — only where the data comes from. Files:lib/catalog/server.ts(loadCatalog),app/catalog/{page.tsx,_data.ts,_components/catalog.tsx},content/shows.ts,lib/db/seeds/canon-roster.ts. -
Claimed artists can now edit their own profile at
/me. A signed-in musician who has claimed their artist page manages their bio, stage name, pronouns, and links there — scoped by account ownership, no intake link required. Files:app/me/{page.tsx,actions.ts,_components/MyProfileEditor.tsx}. -
From the account-linking work: artists can now claim their own profile via a magic link. A 7-day claim link (
/claim/<token>) emailed to a band member proves they own the address and lets them set up an account (email pre-filled and locked to the invited address) — or, if already signed in, take ownership directly. Claiming captures consent to the Artist Grant of Rights with a required clickwrap (covering likeness/imagery, not just music), records a dated receipt, and emails the artist their copy. Files:app/claim/**,lib/email/resend.ts(sendClaimInviteEmail),lib/auth/server.ts. -
Artists can now claim their own profile. Two new ways to send an artist a magic claim link: band leaders get an "Invite {name} to manage their profile" email field on each member card in the Links tab, and every public profile (
/p/<slug>) gains an "Is this you? Claim this profile →" button that emails a claim link. Either path always shows the same "check your email" confirmation (we never reveal whether an email matches a known artist). Claiming itself happens at/claim/<token>. Files:app/intake/[appearanceToken]/_components/MemberLinksSection.tsx,app/p/[slug]/page.tsx,app/p/[slug]/_components/ClaimProfile.tsx. -
From playtester feedback ("I submitted the roster but never got a chance to add band links or a description"): the roster confirmation now points you to the next steps. The band manager's tabs (Roster · Profile · Links · Audio) were independent, so submitting the roster dead-ended on a "you're in" screen with no nudge toward the bio (Profile) or per-member links (Links) — which a band leader naturally wants to do next. The roster success panel now shows a "Next — finish your band page" block with buttons that jump straight to Profile (bio & look), Links (per-member), and Audio. Submitting also triggers a refresh so the Links tab immediately sees the members just created (member links depend on the roster existing first). Files:
app/intake/[appearanceToken]/_components/{IntakeForm,ManageHub}.tsx. -
All five bandmates fit on screen during a battle now. From playtester feedback ("on an iPhone you can only ever see 4 of the band members"). Bands are five strong, but on a small phone the fifth fighter was clipped behind the action tray. Reclaimed the vertical space three ways: (1) cut the "Band vs. Band" chip from the masthead — the names already label each column below it; (2) compacted each fighter card (smaller portrait, genre tag folded onto the name line, tighter stat bars) so a full five-member column fits above the fold; (3) long band names now truncate with an ellipsis everywhere they appear (masthead column labels and the synergy strip) instead of wrapping and pushing the roster down. Files:
app/_components/battle/{battle,fighter-stage,synergy-strip}.tsx. -
"Your turn" push notifications now actually fire during multiplayer battles. The Settings toggle was in place, but nothing was sending — now the server nudges whoever's on the clock the moment the turn flips to them. If you've enabled Browser push for Your turn in a battle (Settings → notifications), you'll get a "Your turn — vs {opponent band}" notification even when the app is closed or backgrounded; tapping it drops you back into the room. It fires for the player who's now up, never the one who just moved, and respects your per-channel preferences (mute it and nothing sends). The in-tab chime/title-flash still covers the case where you're already looking. Sending happens after the move is committed, outside the battle transaction, so a push hiccup can never affect the actual game. Files:
lib/realtime/server.ts(turn-flip hook →notify({ type: "pvp_turn" })). -
A musician is now one character per instrument they play — not a new character every show. The old model minted a brand-new character for every appearance, so a drummer who played five shows became five near-identical collectibles. Now a character's identity is (person + band + instrument): play the same instrument at ten shows and it's one character with ten per-show "skins" (each show's photo/sticker is a variant of the same character); pick up a different instrument and that's a genuinely new character. So one person in a band can yield several fieldable characters — e.g. Daniel on drums, guitar, and synth across three nopeople shows builds a three-Dan nopeople lineup — while the catalog stops filling with show-by-show duplicates. The gameplay role follows the instrument (drums → rhythm, vocals → melody, keys → harmony; an admin can still override), and a band's genre is read from the band itself. Stickers still mint per show and resolve to the underlying character when scanned. Existing characters were migrated by collapsing same-instrument appearances together. Files:
lib/db/schema.ts(characters+character_variantsreplacecharacter_versions),lib/db/migrations/0026_character_instrument_variants.sql(new),lib/instruments.ts(new — instrument→role map),lib/intake/roster-submit.ts,app/api/character-versions/**,app/api/admin/character-stickers/mint/route.ts,lib/{roster,people,stickers,audio,metrics}/server.ts,app/api/mcp/[transport]/route.ts, seeds. Design notes indocs/character-instrument-variants-memo.md. -
Multiplayer battles play music now. From the same playtester session ("a song did not play during this battle").
BattleAudio— the once-per-fight track picker that AI/Practice battles mount — was never wired into the PvP battle screen (pvp-battle.tsx), so multiplayer fights were silent. Mounted it during the battle phase, fed both teams' member ids (same/api/battle/trackslug picker, same muted-by-default speaker chip). File:app/_components/battle/pvp-battle.tsx. -
Band genre is now set once for the whole band — no per-member overrides. The roster intake used to give every member a "Genre (optional override)" dropdown, letting one band span multiple genres. That field is gone: every character inherits the band's genre (resolved server-side from the appearance), so a band is one genre, period. Consequently the Profile tab's genre control is relabeled from "Default genre" to "Band genre" — with overrides removed, "default" no longer meant anything. The server action drops its per-row genre handling and always uses
appearance.band.defaultGenre. Files:app/intake/[appearanceToken]/_components/{IntakeForm,BandProfileSection}.tsx,app/intake/[appearanceToken]/actions.ts. -
The roster form now opens with a "how many in the band?" stepper. Instead of guessing from a fixed grid of 5 rows plus an "+ Add member" button, the band-intake roster (
/intake/[appearanceToken]) leads with a band-size control — a−/+stepper (zine ink buttons, big serif count) that sets how many member cards render, clamped to 3–8. Increasing appends empty cards; decreasing trims from the end. The redundant bottom "+ Add member" button is gone (per-card "Remove" stays); the footer summary now reads "{filled} of {total} filled". Pure client-side form UX — submission/validation unchanged. File:app/intake/[appearanceToken]/_components/IntakeForm.tsx. -
Opponent cutouts show in battle now, not just emoji. From playtester report ("in multiplayer you can't see the other team's cutouts — it falls back to emojis"). The battle stage tile (
FighterStage) only resolved a real-photo cutout whenside === "player", so every opponent member rendered as an instrument emoji even when a cutout existed. That defeated the recycled-enemy mappings inlib/cutouts.ts(e-bones→dan.png, etc., which exist specifically so AI enemies show art too). Fix: resolvecutoutFor(c.id)for both sides, keeping the emoji fallback only when no cutout is registered. Applies to PvP opponents and AI enemies alike. File:app/_components/battle/fighter-stage.tsx. -
New Settings page lets you control how the game reaches you. A new Settings screen (☰ Menu → Settings, at
/settings) gathers your notification preferences in one place. You can enable browser push notifications on a device, then choose per type — your turn in a battle, battle results — which channels reach you (Email, Browser push, Mobile app). Required messages (password reset, your artist-agreement receipt) are listed under "Always sent" so you can see what we send even though those can't be turned off. Mobile-app push shows as "coming soon" — the preference saves now and starts delivering once the native apps ship. Under the hood this is a single notification dispatcher (lib/notifications) every channel routes through, so a muted channel never blocks the others and the same preference applies everywhere. Push needs no setup to use; on a deployment without push keys configured the toggle simply says so. Files:app/settings/{page,notification-settings,actions}.tsx(new),lib/notifications/**(new — dispatcher, email/web-push/native-push adapters, catalog),lib/email/resend.ts(sharedsendEmailcore),public/sw.js(new — push service worker),app/api/notifications/subscribe/route.ts(new),lib/db/schema.ts+lib/db/migrations/0024_notifications.sql(new —notification_preferences,push_subscriptions,device_tokens),app/_components/global-nav.tsx(Settings entry). -
The roster form now opens with a "how many in the band?" stepper. Instead of guessing from a fixed grid of 5 rows plus an "+ Add member" button, the band-intake roster (
/intake/[appearanceToken]) leads with a band-size control — a−/+stepper (zine ink buttons, big serif count) that sets how many member cards render, clamped to 3–8. Increasing appends empty cards; decreasing trims from the end. The redundant bottom "+ Add member" button is gone (per-card "Remove" stays); the footer summary now reads "{filled} of {total} filled". Pure client-side form UX — submission/validation unchanged. File:app/intake/[appearanceToken]/_components/IntakeForm.tsx. -
Jam Session is live: queue up in the green room and get matched person-vs-person. The Play → Jam Session button (previously "coming soon") now opens the green room at
/jam. Pick a band, hit the queue, and matchmaking pairs you with another waiting player close to your skill — a new per-player MMR rating (starting at 1500), updated by simple Elo after each ranked match. While you wait, an in-app radio plays a real track from the catalog (band + song name shown; artwork punted for now), green-room flavor quotes rotate, and two escape hatches — Play Practice while you wait and Check the Catalog — let you wander the app without leaving the queue: a persistent bottom banner keeps you one tap from the green room, and a match interrupts wherever you are and drops both players straight into the battle. The queue pairs on join and on each status poll (the green room polls every few seconds), with a daily cron sweep as a safety net, then pre-seats a shared realtime room so the existing PvP fight takes over from there. Files:app/jam/page.tsx(new),app/_components/queue-provider.tsx(new, mounted globally inapp/layout.tsx),app/_components/play-menu.tsx,app/api/queue/{join,leave,status,sweep}/route.ts(new),app/api/radio/track/route.ts(new),lib/matchmaking/{server,mmr,types}.ts(new),lib/realtime/server.ts(Elo write-back on battle end),lib/audio/server.ts(radio track picker),lib/db/schema.ts+lib/db/migrations/0023_jam_session_matchmaking.sql(new —matchmaking_queuetable +user.mmr),vercel.json(new — pairing cron). -
From playtester feedback ("wtf this is so hard to read"): the band-manager intake link now uses the app's zine style. The self-service hub at
/intake/[appearanceToken](Roster · Profile · Links · Audio) had been built in a dark theme — white/translucent text on dark cards — which was nearly illegible on the app's cream paper background. Restyled all of it to the zine language used everywhere else: cream#f4ecd8paper cards with2pxink borders and hard3px 3px 0drop shadows,#1a1a1aink text, Courier-mono uppercase kickers/labels, italic Georgia display headings, a double-rule masthead with the band name + genre accent chip, ink/paper tab capsules, and filled-ink primary CTAs. The roster slot picker now uses the per-slot accent triad (◼ rhythm / ◆ melody / ● harmony). Pure visual change — no behavior, props, or server-action wiring touched. Files:app/intake/[appearanceToken]/page.tsx,app/intake/[appearanceToken]/_components/{ManageHub,IntakeForm,BandProfileSection,MemberLinksSection,BandAudioSection}.tsx. -
Canon opponent bands now read as a single genre — and always have their genre synergy. Every member of a real canon band (The Garage Gremlins, Neon Nocturnes, Thunder Choir, Final Encore, The Crypt Society, Luana and the Iguanas, nopeople) is now seeded with that band's own genre instead of the character's original mixed genre — so in Practice / opponent-select each canon band shows one genre, and the genre synergy bonus is always live for the AI opponent (no more, e.g., a "punk" band fielding hip-hop and pop members). This is a canon-seed-only change: user-created bands keep per-member genres so the synergy threshold stays a real choice, and combat math in
lib/game.tsis unchanged. The Free Agents container (unaffiliated roster characters, not a real band) keeps its mixed genres. File:lib/db/seeds/canon-roster.ts. -
Battles now record how long they took, shown on the Past Battles page. Each match's wall-clock length is captured and surfaced on its card (e.g.
1m 35s · May 25, 3:55 PMon the result stripe). AI battles are timed client-side from the moment the battle screen opens to the result; PvP matches are timed server-side from the phase→battle transition to theBattleEndedevent, so the number is authoritative regardless of which player's client writes the row. Older battles with no recorded length simply omit it. Honeycomb couldn't answer this — every server trace is a single short HTTP request and AI battles never touch the server during play — so duration is measured where the match actually happens and persisted on the row. New nullableduration_mscolumn onbattle_records. Files:lib/db/schema.ts,lib/db/migrations/0022_battle_duration.sql(new),lib/player-data/server.ts,lib/bands-store.ts,app/api/player/battles/route.ts,app/_components/battle/battle.tsx,app/_components/game-shell.tsx,lib/realtime/types.ts,lib/realtime/server.ts,app/play/[roomId]/page.tsx,app/battles/page.tsx,lib/db/seed.ts. -
The home screen is now a clean menu, and "Play" leads to a new Practice mode. The title screen dropped everything below the masthead (the emoji cover sticker, the "Assemble. Tune up. Rock them." headline, the slot-kicker dek, and the chip row of Build-a-band / Challenge-a-friend / Tutorial / Catalog) in favor of three big paper buttons: Play, Friends List (→
/friends), and Settings (→/account). Play opens a sub-menu of three modes — Practice (person vs AI, live now), Jam Session (person vs person, coming soon), and On Tour (ranked, coming soon). Practice is the band-discovery loop: pick your band, pick a genre, and the game draws a random full band of that genre as your opponent ("You're playing: The Garage Gremlins!"), with that band's members' tracks playing in the battle. A "🎲 Draw another" re-rolls within the genre. The genre picker only lists genres that actually have a full band to draw from. After the battle, the result screen routes back to the Play menu. Band creation is still reachable (the Practice band-picker keeps its "+ Create a new band"); the Tutorial and Catalog now live behind the ☰ Menu drawer rather than the title screen. Files:app/_components/title-screen.tsx,app/_components/play-menu.tsx(new),app/_components/practice-genre.tsx(new),app/_components/practice-intro.tsx(new),app/_components/game-shell.tsx,app/_components/header.tsx,lib/roster/server.ts(EnemyBand now carries the band's genre). -
The winner's track plays on the victory screen. When you win a show, a track from one of your winning band's members now plays automatically over the result screen — the payoff that makes the win feel like their win. It reuses the battle-audio plumbing (
/api/battle/track, same fade + speaker chip) but, unlike the in-fight music which starts muted, the victory anthem defaults to playing (you've already clicked through a whole battle, so the browser lets it autoplay). An explicit mute still carries over via the shared preference, and a 🏆 "Now playing: …" chip names the track with a tap-to-mute toggle. If no winning member has eligible audio, the screen is silent as before. Files:app/_components/victory-audio.tsx, wired inapp/_components/result-screen.tsx. -
Self-service audio uploads now capture a real agreement, not just a checkbox — and email the artist a copy. Previously a band member's first upload through the intake link auto-created a bare "attestation" grant with no record of consent. Now the first upload for a member with no grant on file gates behind a clickwrap consent step: they enter their name + email and tick "I have read and agree to the [Artist Grant of Rights]" (a link opens the full agreement, rendered at the new public
/legal/artist-grantpage fromdocs/artist-grant.md). On submit we snapshot the exact agreement text into a self-contained HTML receipt, store it to Blob as the grant's document, record who agreed / when / which doc version (a content hash of the grant doc, so it auto-bumps when the doc is edited) / the method on theaudio_grantsrow, and email the member their copy via Resend (grant_agreementtype). Members who already have a grant skip the step. The receipt and email are byte-identical. On the grant roster these show as a real "View doc" link instead of "Email on file". Files:app/api/intake/[appearanceToken]/audio/route.ts,app/intake/[appearanceToken]/_components/BandAudioSection.tsx,app/legal/artist-grant/page.tsx(new),lib/legal/{artist-grant,receipt}.ts(new),lib/email/resend.ts,lib/blob/index.ts,lib/audio/server.ts,lib/db/schema.ts,lib/db/migrations/0021_grant_consent.sql(new),next.config.ts. -
Password reset emails actually send now. The reset 403 (below) turned out to be an
EMAIL_FROMprod env var set to an empty string, not unset — andfrom()used??, which only falls back on null/undefined, so the empty string passed straight through and Resend rejected the blank sender withvalidation_error. Confirmed via the newbotb.email.error_namespan attribute on a live attempt. Two-part fix: prodEMAIL_FROMis now set to the verified senderBattle of the Bands <noreply@battleofthebands.app>, andfrom()now usesEMAIL_FROM?.trim() || DEFAULT_FROMso a blank/whitespace value can never silently break sending again. File:lib/email/resend.ts. -
Password reset failures stop hiding. A Honeycomb trace of a real reset attempt showed the request worked end-to-end — user found, token created — but
fetch POST api.resend.com/emailscame back 403 and the user got no email and no error. Root cause: the Resend SDK resolves{ data, error }rather than throwing on a 4xx, andsendPasswordResetEmaildiscardederror, so a rejected send looked like success to Better Auth (UI said "check your inbox"). Fix: inspecterror, stamp the active span withbotb.email.error_name(low-cardinality, groupable in Honeycomb), log the full provider message to server logs, and throw so the failure surfaces instead of dying silently. File:lib/email/resend.ts. -
Fixed: the band-intake roster submit silently did nothing in production. The "Send to photographer" button on
/intake/[appearanceToken]posted a server action that resolved the appearance and saved the roster through the relative-URL HTTP client inlib/intake/api.ts(getAppearanceByToken/submitBandRoster→fetch("/api/…")). Relative URLs have no origin, sofetchthrowsFailed to parse URLthe instant it runs server-side — and inside a server action that throw surfaces as a bare 200 with no state change, so the form just looked dead. It went unnoticed because every prod band's roster came from the canon seed, not the form. The action now talks to the DB directly:requireAppearanceresolves the token and a new sharedsubmitRosterVersions(lib/intake/roster-submit.ts, extracted from the route's replace-all transaction) persists the roster. The API route calls the same function, so there's one source of truth. AGENTS.md now documents the rule: server actions / server components hit the DB directly, never our own/api/*over a relative URL. Files:app/intake/[appearanceToken]/actions.ts,app/api/band-appearances/[id]/roster/route.ts,lib/intake/roster-submit.ts(new),AGENTS.md. -
The band intake link is now a full self-service band manager. Previously
/intake/[appearanceToken]only let a band add its roster for one event; the link a band leader gets is now a four-tab hub (Roster · Profile · Links · Audio) that lets the whole band manage their own presence with no login — the appearance's intake token is still the only credential, so anyone with the link has equal power (the intended "all members" model, kept inside the band). Profile: edit the band name, blurb, default genre, and color. Links: add the bandcamp / streaming / social links that render on each member's public/p/[slug]profile, per member. Audio: each member can upload one song (per band) that plays as battle music whenever any current member is on stage — re-uploading replaces that member's slot rather than piling up, and the song is auto-compressed in the browser to the same Opus 96k / 60s loop the admin upload uses (~700 KB on the wire). Two guards keep the open-link audio surface safe: a content-hash dedupe (sha256 of the original file, computed client-side) blocks the same source file landing twice for the band — backstopped by a partial unique index on(band_id, content_hash)— and an ownership attestation checkbox ("this is our song and we have the rights to share it") gates a track into rotation; unchecked, it uploads but stays out of play until rights are confirmed. Since members can't sign a PDF grant, an attestationaudio_grantis created on the fly on first upload. Every write is scoped to the band the token resolves to (band id is derived from the token, never trusted from the form), and per-member writes are gated by a band-membership check so a token can only touch its own band's people. New shared token-auth helperlib/intake/auth.ts(requireAppearance/personIsInBand/listRosterPeopleForBand) is the single chokepoint all the surfaces gate on. Files:app/intake/[appearanceToken]/page.tsx,app/intake/[appearanceToken]/_components/{ManageHub,BandProfileSection,MemberLinksSection,BandAudioSection}.tsx,app/intake/[appearanceToken]/actions/{band-profile,member-links}.ts,app/api/intake/[appearanceToken]/audio/route.ts(+[trackId]DELETE),lib/intake/auth.ts(new),lib/audio/server.ts,lib/db/schema.ts,lib/db/migrations/0020_audio_track_content_hash.sql(new). -
Sign out button shows "Signing out…" while it's working. From playtester report ("when i press this signout button nothing happens"). The button kicks off
authClient.signOut()(a real network round-trip) and arouter.refresh()before the page can re-render into the Sign-in form — on a slow mobile connection that's a noticeable beat with zero visual change, so the tap looks dead. The handler now sets the existingbusyflag for the duration; the button disables, drops its hard-shadow, and swaps its label toSigning out…(and the "Make it permanent →" button disables alongside it so neither can be double-fired). Any thrown error fromsignOutis now surfaced via the sameerrorpanel the form uses instead of being swallowed. File:app/account/page.tsx. -
Sign out actually signs you out. From playtester report ("the sign-out button on the website doesn't do anything"). The button on
/accountwas correctly clearing the Better Auth session — butEnsureSession(the layout-mounted guard that auto-mints an anonymous session on every page except/accountand/s/) was minting a fresh anonymous account the instant the user navigated anywhere else, so from the user's POV sign-out was a no-op. Fix: the sign-out handler now writes abotb:signed-outflag intosessionStoragebefore callingauthClient.signOut();EnsureSessionchecks that flag and skips its auto-mint while it's set. The flag is cleared the moment any new session appears (real sign-in or an explicit "Play as guest" click), and it dies with the tab, so the QR-scan first-touch flow that depends on auto-anonymous is unaffected. Files:app/_components/ensure-session.tsx,app/account/page.tsx. -
Audio uploads auto-compress to Opus; new prod workflow re-encodes legacy tracks; docs land the trade-off. Three follow-ups to the audio data-savings work below. (1) Admin upload pipeline now transcodes in the browser. The
/admin/people/[id]and/admin/bands/[id]upload forms gained a "Compress for mobile (Opus 96k, 60s loop)" checkbox (default on) and a "Start at second" input. Newlib/audio/compress-client.tslazy-loads@ffmpeg/ffmpegin single-threaded mode (no COOP/COEP needed) the first time an admin compresses, caches the WASM core for the session, and produces anaudio/oggBlob with the same encoder config the local + CI scripts use. Admins now upload ~700 KB instead of ~5 MB; the server-side route stays untouched. (2) Universal re-encode workflow..github/workflows/audio-reencode-opus-prod.yml(workflow_dispatch only) installs ffmpeg on the runner and runs newscripts/reencode-audio-opus.ts, which walks everyaudio_tracksrow, downloads each track's current blob, transcodes to Opus 96k / 60s with per-titlestartSecondsoverrides, and writes back in place. Idempotent: rows already atcontent_type=audio/oggand the target loop length skip. Sister to the WAV-master script — use this one when you don't have the source on disk. (3)docs/audio-encoding.mddocuments the encoder choice (why Opus, not AAC, not a neural codec — including the "you'd ship 25 MB of WASM to play 800 KB of song" math), the three entry points and when to reach for each, and how to add a per-title start offset. AGENTS.md links to it so future-me reads the doc before touching audio code. Files:app/admin/people/_components/AudioSection.tsx,app/admin/bands/_components/BandAudioSection.tsx,lib/audio/compress-client.ts(new),scripts/reencode-audio-opus.ts(new),.github/workflows/audio-reencode-opus-prod.yml(new),docs/audio-encoding.md(new),AGENTS.md,package.json(+@ffmpeg/ffmpeg,@ffmpeg/util). -
Battle audio: tracks re-encoded as 60s Opus 96 kbps loops; full song download skipped when muted. From playtester report ("audio playback worked for one of the songs but not for Bike the Presidio") and a follow-on data concern ("i don't wanna drain folks' data"). Two stacked wins. First,
preload="auto"→preload="metadata"on the<audio>inapp/_components/battle/battle-audio.tsxso the browser only fetches a few KB of header until the user unmutes — the full track body is deferred toplay(). Second, newscripts/reprocess-nopeople-audio-opus.tsreads the mastered WAVs (/Users/lee/fun/command_center/nopeople/releases/badidea/*.wav), trims a 60-second window starting at a per-track offset (30s for Bad Idea / Wide Awake / That's What I Need; 75s for Bike the Presidio to skip its long intro), applies 0.5s fade-in + 0.5s fade-out so the<audio loop>seam doesn't click, and encodes Opus 96 kbps in an Ogg container. Opus is transparent for stereo music at 96k and decodes natively in every modern browser (Safari 17.4+); 60s is more than most battles take so the loop seam rarely matters. The script UPDATES existingaudio_tracks.audio_url/audio_pathname/content_type/duration_secondsin place — track ids, grant ids, and band linkage all stay intact — and best-effort deletes the old MP3 blob. Sizes: Bad Idea 3.5 MB → 642 KB (82% smaller), That's What I Need 3.6 MB → 696 KB (81%), Wide Awake 4.3 MB → 590 KB (86%), Bike the Presidio 6.8 MB → 791 KB (88%). As for why we didn't reach for a neural audio codec: EnCodec / Descript Audio Codec / SoundStream get to 6–16 kbps for music with quality comparable to Opus 64k, but no browser decodes them natively — you'd ship a 25 MB WASM ML runtime per page load to play 800 KB of song, which is upside down. Opus is the right answer for the web in 2026. Files:app/_components/battle/battle-audio.tsx,scripts/reprocess-nopeople-audio-opus.ts. -
Tutorial portraits now use the real-photo cutouts, matching the main battle stage. Follow-up to the starter-band repaint: the tutorial's three portrait surfaces (
FighterTile,CharacterRevealCard, the pre-battle opponent chip) were all renderingc.emojiinside a colored genre-accent disc, while the main-game battle stage was already showing the realpublic/cutouts/{id}.pngphotos viacutoutFor(). New shared<CharacterPortrait />helper insideapp/tutorial/page.tsxmirrorsFighterStage's idiom — same disc + halftone wash,next/imagecutout ifcutoutFor(c.id)returns a path, emoji fallback otherwise, optionalmirrorfor the enemy side flip — and all three sites now go through it. Both starter bands have cutouts for 9/10 members (luana,jake,nate,josh,daphne,lee,dan,dax,brant);andrewfalls back to 🎤 until his photo is shot and dropped intopublic/cutouts/+ registered inlib/cutouts.ts. The tutorial's StarterCard member chips and the starter band-header emoji stay as emoji (they're tiny info chips, not portraits, and match the rest of the app). File:app/tutorial/page.tsx. -
botb-admin-mcp: OAuth discovery no longer 404s, and auth failures now log the real cause. Two bugs were stacking up to make the in-repo MCP server look broken for any client that didn't hard-code a
Bearer botb_…header in.mcp.json. (1)mcp-handler's auth wrapper unconditionally advertisesWWW-Authenticate: Bearer … resource_metadata="<origin>/.well-known/oauth-protected-resource"on every 401 — modern MCP clients (Claude.ai connectors,mcp-remote, Claude Code's reconnect-with-OAuth path) then fetch that URL to discover the auth flow. The route didn't exist, so they got the app's 404 HTML and dropped the connection. Newapp/.well-known/oauth-protected-resource/route.tsreturns a minimal RFC 9728 metadata document viagenerateProtectedResourceMetadata, withauthorization_servers: []+bearer_methods_supported: ["header"]to signal "bring your own bearer," andresource_documentationpointing at/admin/tokensfor humans. (2) The route'sverifyTokeninapp/api/mcp/[transport]/route.tsreturnedundefinedfor three different failure modes — no header, bad/unknown/revoked token, and demoted user — which the wrapper rendered identically aserror_description="No authorization provided". So a revoked token or a token from a user who lost admin role looked exactly like a missing header, making the failure undebuggable. NowverifyTokenonly returns undefined when the header is genuinely absent andthrowsfor the supplied-but-rejected cases; the wrapper's catch path keeps the public message generic ("Invalid token"— correct semantics) butconsole.errors the thrown message, so Vercel logs show e.g.mcp-auth: user x@y.com role=user, admin requiredinstead of nothing. -
Tutorial: starter band picker is now Luana and the Iguanas vs nopeople, and the ☰ MENU trigger is pinned to the top-left of every stage. From playtester request: the demo flow should let a brand-new visitor sign one of the two real starter bands the app exists to promote, and the menu drawer should always be one tap away — not just on the intro and battle screens.
STARTER_BANDSinlib/tutorial.tsreplaces the three fictional starters (Crypt Society / Garage Gremlins / Neon Nocturnes) with the two canon starter bands; member order is pinned for the pedagogy beats (melody → harmony → rhythm, then the final two), and the picker grid drops from 3 to 2 columns.stageOpponentsnow picks the other starter as the rival across all four stages, so the choice feels like a real fork (pick Luana → fight nopeople incrementally, ending with the full power-pop band; pick nopeople → fight the Iguanas, ending with Luana herself). Tutorial intro copy updated from "three STARTER BANDS" to "two". The five tutorial screens that weren't already renderingSkipBar(starter-select, all four StagePhase non-battle sub-screens, complete) now mount a new<TutorialMenuCorner />— fixed top-leftMenuTriggerwith the sametone="light"paper-stamp styling SkipBar uses — so the menu lives in the same spot on every screen, and the layout-level fallback puck stays auto-suppressed viabody.dataset.menuTriggerCount. Files:lib/tutorial.ts,app/tutorial/page.tsx. -
Catalog: starter bands (nopeople + Luana and the Iguanas) render fully revealed for every user, no scanning required. Driven by wanting the anonymous-account flow to work as the demo experience — a brand-new visitor lands on
/catalogand should immediately see the real people the app exists to promote, not silhouettes. New constantSTARTER_BAND_IDSinlib/roster/canon.tsenumerates the canon starter bands (currentlynopeople+luana-and-the-iguanas); the catalog readsenemyBandsfrom the roster context, derives the union of starter member ids viauseMemo, and threads it through a newrevealedCharacterIdsparameter ongetCardState(app/catalog/_data.ts). Cards whosecharacterIdis in that set short-circuit to"revealed"ahead of the existing seen / synthetic-bucket / adjacency logic; the rest of the catalog UX (silhouettes for unseen enemy bands, the REVEAL ALL toggle, the synthetic 35% random-reveal baseline) is unchanged. No DB writes — purely a render-time always-on for two band ids, so adding a starter band is a one-line edit toSTARTER_BAND_IDS. Verified at 1280×900: both starter rows render in full color (teal Luana, pink nopeople); the five "enemy" canon bands keep their mix of silhouettes + random reveals. Files:lib/roster/canon.ts,app/catalog/_data.ts,app/catalog/_components/catalog.tsx. -
Tutorial top bar:
← HOMEreplaced by the☰ MENUtrigger; SKIP button no longer collides with the floating puck. From playtester observation ("the menu button on mobile in the tutorial sits on top of another button at the top right. Do we need a HOME button at the top left if we have a menu button that opens a side-drawer?"). The tutorial'sSkipBarwas the last surface still pairing a dedicated← Homelink (top-left) with the layout-level floating MENU puck (top-right) — and the puck was landing on top of theSkip intro/Skip battle →button. Replaced the← Homelink with<MenuTrigger tone="light" />: the drawer covers home navigation (and everything else) without burning a corner on a single destination, and mounting the in-page trigger registers viabody.dataset.menuTriggerCountso the floating puck auto-suppresses, leaving Skip alone on the right. Verified at 390×844: paper-stamp MENU capsule top-left, paper-stamp SKIP INTRO capsule top-right, no overlap. File:app/tutorial/page.tsx. -
Prod canon-roster seed now runs in CI; catalog / team-builder / battle no longer render empty after the unhardcode refactor. The PR 2 refactor moved ROSTER + ENEMY_BANDS out of in-memory constants and into the DB. Schema-only migrations auto-deploy via
db-migrate-prod.yml, but that refactor didn't change any tables — it just relied on the canon-roster seed having run. Locallypnpm db:seedcovered it; in prod the seed never ran, so the runtime loader (lib/roster/server.ts, which reads onlyband_appearancesat the syntheticCANON_EVENT_ID) returned empty arrays and every roster-driven surface rendered nothing. Caught by a Playwright pass at alpha.battleofthebands.app/catalog showing0 / 0 collectedwith no console errors. Fix: standalone scriptscripts/seed-canon-roster.tsruns onlyseedCanonRoster()(no playtest / sandbox / metrics fixtures — those would be wrong in prod), wrapped in pnpm aliasesdb:seed:canon(dotenv → docker DB) anddb:seed:canon:apply(raw DATABASE_URL, used in CI). New workflow.github/workflows/db-seed-canon-prod.ymlruns the apply target againstPROD_DATABASE_URLon push to main whencontent/roster.ts,lib/db/seeds/canon-roster.ts,lib/roster/canon.ts, the script, or the workflow itself change — same secret + same pattern asdb-migrate-prod.yml. Idempotent under the hood, so manualworkflow_dispatchis also safe. -
Last five dark-themed surfaces converted to zine — entire play loop now reads as one issue. Follow-on to the zine theme commit: with the page bg flipped to paper and the title masthead landed, the remaining dark-cosmic screens (
game-mode-select,opponent-select,mode-select,result-screen) and the partly-convertedtour-logwere the last placestext-white/Xand gradient cards still showed through, sitting awkwardly on the cream paper bg. Five parallel agents converted them on disjoint files:app/_components/game-mode-select.tsx— three paper cards (AI / Multiplayer / Competitive) with 2px ink borders +3px 3px 0hard ink shadows that bump to5px 5px 0on hover viatranslate(-2px,-3px). Italic-serif titles, mono-caps taglines, Georgia descriptions. Beta badge becomes a punk-red#E64A2Epaper chip.app/_components/opponent-select.tsx— band cards on paper with HP pills + synergy stamps backed byGENRE_ACCENT[genre]so each band reads in its own pen-ink color (Garage Gremlins punk red, Neon Nocturnes electronic purple, Thunder Choir metal black, etc.). 5-member lineup chips mirrorband-formed.tsx's pattern — genre-accent disc holding the emoji, name + slot+genre in mono caps below. Empty-state "no player-touring bands yet" moved to ZINE.MUTED mono caps.app/_components/mode-select.tsx— two paper cards (Manual / Auto) using the sameModeCardzine treatment as game-mode-select.app/_components/result-screen.tsx— wrapped in a paper section withpaperBgStyle+ 2px ink border +4px 4px 0ink shadow. Eyebrow— Set Closed —, italic-serif headline at 40px, win gets the punk-red+ink double drop shadow (3px 3px 0 #E64A2E, 5px 5px 0 #1a1a1a), loss flattens toZINE.MUTEDwith no shadow. 🏆 / 💀 shrunk to 36px decoration. Rematch is the inverted ink-on-paper primary CTA (matches Take-the-stage on band-formed); the others use a sharedPAPER_CAPSULEmodeled onZINE_CAPSULE_BASE. Share-thoughts link preserved as a mono-caps dotted-underline. All handlers /openFeedbackplumbing untouched.app/_components/tour-log.tsx— swept three straytext-white/text-white/60patches that were rendering as nearly-invisible text on the paper bg; replaced withZINE.MUTEDand an inline hover-to-ZINE.INKhandler on the back button.
Type-check clean. Verified visually at 390×844 on title → bands picker → game-mode-select → opponent-select → mode-select → tour-log; result-screen verified via code review (the auto-battle was too long to drive interactively in the verify window, but the conversion mirrors band-formed's idiom exactly).
-
Title page reworked into a broadsheet masthead; whole app now wears one (zine) theme. From playtester feedback ("we have two competing themes here and on the homepage. Make everything the zine theme, and you can lean towards the Guitar Hero mess if you want") and a follow-up ("we can drop this whole header in favor of the AUX: THE GATHERING ISSUE 1 style header. As if the whole homepage were itself a zine"). Two changes:
- Page background and global chrome moved to zine.
app/globals.cssswapped the dark cosmic#07060dbody bg + purple/cyan radial gradients for a cream-paper background with a punk-flyer halftone splotch field — low-alpha bursts of zine-accent color (punk red, electronic purple, pop pink, hip-hop yellow) over a 4px dotted ink halftone,background-attachment: fixedso the bursts anchor to the viewport like a photocopied flyer instead of a tiling sheet.app/_components/header.tsx's in-flow row (phase stepper · 🏠 Main menu · Rules) became zine paper-stamped capsules (paper bg, 2px ink border, 2px hard ink shadow, mono caps), and the stepper steps are now ink chips with a hard ink shadow on the active/done state instead of fuchsia-translucent dark pills. The white-translucent header chrome that used to read against the dark gradient is gone. - Title screen is now the front page of an issue. Dropped the layout-level
Battle of the Bandswordmark + JRPG-tagline header on the title phase entirely.TitleScreen(app/_components/title-screen.tsx) now owns a proper broadsheet masthead — edition strip (ISS. №1·Aux Edition), an oversized italic-serif wordmark with a punk-red ink drop-shadow, paired kickers ("JRPG band brawler" · "Live from your phone") in mono-caps, top + triple-rule bottom hairlines. Modeled on the daily-edition style notes at~/fun/command_center/style/daily-edition/. The title Header (app/_components/header.tsx) reduced to a 2-element nav strip:☰ Menuon the left,Rulescapsule on the right. Files:app/globals.css,app/_components/header.tsx,app/_components/title-screen.tsx.
- Page background and global chrome moved to zine.
-
Catalog: nopeople + Luana and the Iguanas now show up. Follow-up to the unhardcode-the-roster refactor: the catalog's
SHOWSliteral (hand-curated Indianapolis date/venue flavor not yet modelled in DB events) only listed the five "enemy" bands — Garage Gremlins, Neon Nocturnes, Thunder Choir, Final Encore, the Crypt Society — so the two starter bands had zero cards even though the DB-backed roster loader returns them inenemyBands. Added twoSHOWSentries per starter band (Pioneer + Hi-Fi for Luana, the Vogue + Healer for nopeople) and gave both bands aBAND_FLAVORaccent (alt-jangle teal, power-pop cyan) so their detail headers don't fall back to the dim default. Files:app/catalog/_data.ts,app/catalog/_components/catalog.tsx. -
Menu trigger moved to the left edge on every page. Follow-up to the same playtester instinct: "why in christ's name would you put the menu button at the top right when the left-slide menu drawer is on the left?" Fair point — the trigger should be on the same edge as the gesture it triggers, otherwise the user's thumb has to cross the screen to open the drawer that just slid in next to where they started. The trigger now sits leftmost on every header: in the game-shell title header (before the wordmark on title; before the phase stepper in-flow), and in each standalone-page masthead row (before
← back to the gameon /rules /catalog /battles /friends, above the eyebrow on /scan /account, before the wordmark on /admin). Files:app/_components/header.tsx,app/rules/page.tsx,app/battles/page.tsx,app/friends/page.tsx,app/catalog/_components/catalog.tsx,app/scan/page.tsx,app/account/page.tsx,app/admin/layout.tsx. -
<MenuTrigger />adopted on every standalone page; floating puck retires almost everywhere. Follow-up to the iOS-style drawer landing earlier: every standalone page that wasn't the game-shell now drops the slim☰ Menucapsule into its existing masthead/back-link row, so the layout-level fallback puck stays hidden across/rules,/catalog,/battles,/friends,/scan,/account, and/admin. The fallback puck is now only doing its job on/tutorial(intentionally — the tutorial intro is sacred and the global SkipBar already owns the top edge). Newtone="light"variant on<MenuTrigger />(paper-on-ink chunky capsule) for the zine mastheads; existingtone="dark"(white-on-translucent pill) keeps working for the game-shell + admin headers. Catalog's masthead reclaims its top row (REVEAL ALLtoggle had been pushed down to dodge the old puck; trigger sits beside the back-link now). Files:app/_components/global-nav.tsx(newtoneprop),app/rules/page.tsx,app/battles/page.tsx,app/friends/page.tsx,app/scan/page.tsx,app/account/page.tsx,app/admin/layout.tsx,app/catalog/_components/catalog.tsx. Verified at 390×844: every listed route readsbody.dataset.menuTriggerCount = "1"with no.zine-wobble-puckin the DOM. -
Roster + canon bands are no longer hardcoded — every consumer reads from the DB. Follow-up to the audio-for-nopeople fix below: the
ROSTERandENEMY_BANDSarrays inlib/game.ts(the working source of truth for ~39 characters and 7 canonical opponent bands) have been deleted. The literal seed data moved tocontent/roster.tsand is only consumed bylib/db/seeds/canon-roster.ts; at runtime gameplay reads from the DB via a new loader stack —loadRoster()/loadEnemyBands()(server, Reactcache()'d per request,lib/roster/server.ts) feeding a<RosterProvider>mounted in the root layout, with auseRoster()hook (lib/roster/client.tsx) handing the same{ roster, enemyBands }shape to every client component. Sixteen consumer sites swapped theirimport { ROSTER }foruseRoster()(battle, team-builder, band-picker, band-formed, opponent-select, tour-log, game-shell, heard-chip-row, catalog + its sub-views, both tutorial pages, past-battles); the catalog's static_data.tsbecame abuildCatalogData(roster, enemyBands)factory so its helpers (characterById,cardsForBand, etc.) close over the loaded data instead of module-level constants;lib/tutorial.tsandlib/tutorial-spotlight.tstake roster as a parameter; the realtime multiplayer intent handler is now async andawaits the loader before resolving band members. The shared canon ids (CANON_EVENT_ID,FREE_AGENTS_BAND_ID) live in a tiny standalone modulelib/roster/canon.tsso the runtime loader doesn't pull the seed/content modules into the bundle. Net result: editing a character's stats now means editingcontent/roster.tsand runningpnpm db:seed(idempotent); a hot DB edit via /admin will surface the same way on the next page navigation. PR 2 of the unhardcode-the-roster effort. -
Global nav: left-slide drawer behind an in-header trigger (mirrors the iOS app). The fixed top-right
☰ Menupuck — which kept colliding with page content (the bands picker's🏠 Main menubutton, the catalogREVEAL ALLtoggle, the team-builder header) and didn't match how the iOS app navigates — now opens as a left-slide drawer that matches the iOSMenuDrawerpattern: paper background, ink-stroked right edge,— BACKSTAGE —eyebrow, italic-serif "Menu" headline, dual ink hairline, and the destinations rendered as zine-stamped rows with a→chevron. The trigger lives inside the page header as a slim☰ Menucapsule next to "🏠 Main menu" / "Rules" instead of floating above content.<GlobalNav />(mounted at layout level) still owns the drawer state + dismiss logic; new<MenuTrigger />component drops into any header to open it via a module-level event bus. Pages that haven't adopted the in-header trigger yet (e.g./rules,/catalog,/battles) fall back to the old floating puck —<MenuTrigger />reference-counts viadocument.body.dataset.menuTriggerCountso the fallback hides itself the moment any in-page trigger mounts. The battle-phase hide rule still applies to the fallback puck (so it can't sit on action buttons). Files:app/_components/global-nav.tsx(rewrite — exportsGlobalNav,MenuTrigger,openGlobalMenu),app/_components/header.tsx(renders<MenuTrigger />in both title and in-flow header rows). -
Audio plays during shows against nopeople (and Luana and the Iguanas). From playtester feedback ("I don't hear audio playing when I play a show that contains members of nopeople"). Root cause: every canonical band (nopeople, Luana and the Iguanas, the Garage Gremlins, Neon Nocturnes, Thunder Choir, Final Encore, the Crypt Society) lived only in the hardcoded
ENEMY_BANDSarray inlib/game.ts. The audio picker matches tracks by(track.person = the fighter) OR (track.band ∈ bands this person actually appears in), walkingband_appearances+character_versionsin the DB — and since none of these bands had DB rows, the band-side path could never fire for their members, so any band-uploaded tracks were invisible during their fights. Mirrored every band and every ROSTER member into the DB via a new idempotent seed (lib/db/seeds/canon-roster.ts): one synthetic "Canon Roster" event, seven canonical band rows, oneband_appearanceper band at that event, deterministic-UUIDpeoplerows for every character, and acharacter_versionsrow per (member × band) with the gameplay snapshot. Re-run safe — looks uppeopleby slug so existing Sandbox Demo profile fields (bio/links/image) are untouched. Also extendedlib/db/validators.tsmoveSchemato include the four newer move kinds (redistribute-sp,aoe-shield,looper,clipping) so Down Beat Devin, Anchor Anika, Looper Lou, and Clipper Cass type-check through the DB boundary. This is PR 1 of the unhardcode-the-roster effort; consumers still read from the in-memoryROSTER/ENEMY_BANDSarrays, and the follow-up PR will swap them to read from these rows via a server-side loader. -
"Band formed!" celebration interstitial after creation. From playtester feedback: locking in a freshly built band used to dump the player straight into "Pick a show type" with zero acknowledgement of the milestone they just hit, and the title-screen "Build a band" entry path actually shunted them back to the bands picker ("choose your band") instead of into a battle. New phase
band-formedsits between team-builder and game-mode-select on every create path. It celebrates with a— BAND FORMED —eyebrow, a "🎉 Your band lives." headline, the full lineup as zine-stamped chips, and a singleTake the stage →CTA into game-mode. If the band landed with the defaultcreateBand()fallback name "Untitled Band" (i.e. the player didn't type one in the team-builder), the celebration shows aNAME YOUR BANDinput above the CTA — last chance to grab a name before the show — andupdateBand()writes it on confirm. Both creation entry points (title chip and bands-picker+ Create) now flow through this; the build-only side trip back to the bands picker is gone. Files:app/_components/band-formed.tsx(new),app/_components/game-shell.tsx,app/_components/header.tsx(new phase string; stepper maps it toBand). -
In-flow pages: drop the duplicate wordmark header; rename "Select your band" → "Build your band". From playtester feedback ("looks like we have a legacy header on the top of some of our pages"): the
Battle of the Bandswordmark + tagline rendered above every in-flow phase, but each of those phases already has its own page heading (Pick a band,Build your band,Pick a show type, etc.) so the wordmark just ate ~25% of the mobile viewport for nothing. The full header now renders only on the title screen; every other phase gets a slim controls-only row (mobile phase stepper · Main menu · Rules). Also renamed the team-builder headline fromSelect your bandtoBuild your bandto match the title-screen chip copy. Files:app/_components/header.tsx,app/_components/team-builder.tsx. -
Menu buttons wobble on hover. The global ☰ Menu puck and each destination in its dropdown now do a tiny bouncy tilt on hover (and on keyboard
:focus-visible) — overshoot easing viacubic-bezier(0.34, 1.56, 0.64, 1)so the motion has the snap of a Pizza-Uncommon / sentry.io style button instead of a flat linear lift. The destination links alternate tilt direction per row (--wobble-rot: -2degon even indices,+2degon odd) so the stack rocks playfully instead of all leaning the same way. The puck keeps its 1.5° rest tilt and rocks the opposite way + scales 5% on hover.prefers-reduced-motion: reducekills the transition so motion-sensitive users see the static end state instantly. Files:app/globals.css(new.zine-wobble+.zine-wobble-puckclasses),app/_components/global-nav.tsx. -
Catalog: cart UI repurposed into a band-builder. The cart placeholder kept after the 2026-05-20 commerce revert (b3f4a73) is now the actual band-builder surface — the corner
+on each catalog card drafts that character into a new lineup instead of adding a sticker to a cart. Only revealed characters expose the+(you can only draft what you've discovered); the button shows✓in emerald once the character is in the draft. The floating sticker on the right edge of the catalog is now🎸with anN/5badge (punk-red while building, emerald when full); opening it shows a name input, a rhythm/melody/harmony slot meter against theMAX_PER_SLOT=2rule, the five-slot lineup with×to remove members, and aSave band →CTA that commits the band throughcreateBand()→POST /api/player/bands. Roster constraints fromcanAddToTeam()apply during the draft, so you can't over-pack a single role or duplicate a character. Card-detail CTA mirrors the same three states:Add to band,✓ In your draft band — Remove, or a disabled button explaining why (band full, slot full). Files:app/catalog/_draft-band.ts(new, replaces_cart.ts),app/catalog/_components/band-builder-overlay.tsx(new, replacescart-overlay.tsx),app/catalog/_components/catalog.tsx. -
Catalog masthead:
REVEAL ALLtoggle moved off the top-right. The global☰ Menupuck (a3f2d0a) sits fixed at top-4/right-4 on every page, which was overlapping the REVEAL ALL button in the catalog masthead's top row. Top row is now back-link only; the toggle moved down next to theX / Y collectedcounter where it has clearance from the puck. -
Band builder: class tab denominators now reflect what can still fit. From playtester feedback ("make the MEL 0/2 number update based on slots left + characters available"). The class filter tabs previously read
RHY 1/2 · MEL 0/2 · HAR 0/2using the staticMAX_PER_SLOT(2) as the denominator, so a band with 4 members and 1 slot left still claimed "you can have 2 melody" — even though only one seat was open. The denominator is now a dynamic cap =current + min(MAX_PER_SLOT − current, remainingSlots, untakenRosterChars[class]), so it shrinks as constraints bind. Examples: 4/5 with no melody yet →MEL 0/1; 5/5 with 1 melody →MEL 1/1 ✓; empty band → unchangedMEL 0/2. The ✓ full-state marker also triggers off the dynamic cap. -
Band builder: class color bar surfaces on every character sticker. From playtester feedback ("add the color code bar to the top of these genre cards so I can know the color association without clicking"). The class accent stripe (rhythm / melody / harmony color) at the top of each
PortraitStickerwas 6px tall and partially clipped by the new+ ADDcorner button — players couldn't learn the color → class mapping from the roster grid alone. Bumped to a 14px bar with the class symbol + 3-letter label (◇ MEL, ■ RHY, ◆ HAR) stamped inside it, dropped the redundant bottom-left class symbol that duplicated the same info, and moved the+ ADDquick-add badge from top-left to bottom-right so it stops covering the bar. The color → class association is now legible from the first scroll without tapping anything. -
Band builder: quick-add + slot replace + reset. Three small affordances on the team-builder page. (1) Each character sticker now has a
+ ADDcorner stamp that adds the character to the band in one tap, no preview detour — and reads+ SWAPwhen the player is in replace mode. (2) Tapping a filled slot (mobile tray tile or desktop sidebar row) enters "replace mode": the roster filter snaps to that slot's class, a banner across the top names who's being replaced, and the next character chosen swaps in at the same position. Class tabs that don't match the slot are disabled while replacing; the explicit ✕ on each filled slot still does a plain remove (mobile gets a new corner ✕ for parity with desktop). (3) A✕ Resetbutton appears beside📖 Genresin the masthead the moment the band has at least one member, clearing the lineup, replace target, and preview in one tap. File:app/_components/team-builder.tsx. -
Genre advantages modal polish. Renamed
TYPE ADVANTAGES→GENRE ADVANTAGES(and the launcher button reads📖 Genres); added a real✕close button at the top-right of the card so players don't have to guess that the backdrop is tappable; demoted "tap anywhere to dismiss" to a quiet italic caption above a dashed divider so it stops competing with the family-genres legend just above it. File:app/_components/type-advantages-modal.tsx. -
Catalog chronological view: show all 5 cards per show in one row. From playtester feedback ("you need to show all 5 OR make it super clear there are more in the carousel"). The chronological sort previously rendered each show's lineup as a horizontal-scroll strip with fixed 132px cards — on a 390px viewport only ~2 cards were visible at once with no scroll affordance, so most players never realized more existed. Switched to a fixed 5-column grid (
repeat(5, minmax(0, 1fr))— theminmax(0, …)is load-bearing, otherwise long character names force uneven columns and horizontal overflow). Cards shrink to ~67px wide on phones, ~110px on desktop; every band slot is visible at a glance. Also bumped the corner "+" cart button's hit area from 30×30 to 44×44 (transparent wrapper around the same 30×30 visible disc) so it meets the touch-target minimum on the smaller chronological cards. File:app/catalog/_components/catalog.tsx. -
UX audit pass — six fixes from the same playtest. A walk-through at 390×844 surfaced one P0 and a cluster of paper-cuts; this entry consolidates all of them.
- P0: bottom-corner widgets covered the battle action buttons on mobile.
document.elementFromPoint(85, 800)at battle-phase resolved to the☰ Menupuck instead of the leftmost action; the💬 Feedbacktab partially covered the right action. Both widgets now readdocument.body.dataset.gamePhase(kept in sync byapp/_components/game-shell.tsx) via aMutationObserverand returnnullwhen the phase isbattle. Files:app/_components/global-nav.tsx,app/_components/feedback-widget.tsx,app/_components/game-shell.tsx. After-fix hit-tests confirm Basic + Special action centers and edges resolve to the action buttons. - Header had three reset paths and no mobile wayfinding. The H1 "Battle of the Bands" was a hidden
<button onClick={onReset}>(no affordance) sitting next to an explicit "🏠 Main menu" button, and the phase stepper washidden sm:flex. The H1 is now a plain heading; reset routes through one "🏠 Main menu" button; a compact mobile stepper (5 pips + active step label + N/5) sits under the wordmark on phones. File:app/_components/header.tsx. - Title screen reduced from five same-weight CTAs to one primary + chip row. "Take the stage →" is now the dominant action; Build a band / Challenge a friend / Tutorial / The Catalog collapse into a mono-type chip row beneath. Handlers and routes preserved. File:
app/_components/title-screen.tsx. - Game-mode copy. "No consequence · No benefits" (read as a warning) → "Casual match · Counts toward your record" with body "Fight a touring band run by the CPU. Good for practice — your record updates, but nothing's on the line." Competitive paragraph dropped the unexplained "(the record store)" aside. Back label normalized to "← Back" across game-mode-select, opponent-select, mode-select. Files:
app/_components/game-mode-select.tsx,app/_components/opponent-select.tsx,app/_components/mode-select.tsx. - Bands picker decluttered. Edit (✎) and Delete (✕) moved behind a
⋯overflow next to "Take the stage" so destructive controls aren't a thumb's width from the primary action. The on-tour state lost its giant green banner in favor of a small🎤 ON TOURpill; off-tour is a clean "Send on tour" toggle. Each card now shows anN/5 MEMBERSmeter so the title-screen claim "Build a band of 5" reads honestly against partial bands. File:app/_components/band-picker.tsx. - Battle screen cleanup. Ripped the per-character
♡ Save to Heardbuttons fromFighterStage— meta-progression no longer lives on the combat cards (post-battlemarkCharactersSeenstill runs fromgame-shell.tsx, and theHeardChipRowon the result screen still lets players favorite explicitly). Top chrome collapsed from "✕ Exit + 🏠 + Rules ↗" to a single "🏠 Exit" button. Action card now reads with hierarchy: smallBASIC/SPECIALeyebrow, italic display-serif move name (16px), and a small cost/effect caption — instead of three same-weight chips of equal volume. Audio prompt placeholder "Tap to hear Tempo Test — SoundHelix Song 1" replaced with "Tap to play battle music" so dev fixture names don't leak. Files:app/_components/battle/fighter-stage.tsx,app/_components/battle/battle.tsx,app/_components/battle/bottom-panel.tsx,app/_components/battle/battle-audio.tsx.
- P0: bottom-corner widgets covered the battle action buttons on mobile.
-
Admin:
/admin/balanceeditor + version history. New sidebar tab (⚖️ Balance) lets admins tune every numeric balance lever the engine reads: synergy multipliers per genre (metal HP %, punk cost reduction, pop heal %, folk buff %, electronic +BPM / shield %, hip-hop damage %, goth lifesteal, emo damage when below half HP, alternative damage), crit and miss curves (base, type bonuses/penalties, DPS bonus, max chance, multiplier), looper growth base, vamp default rate, plus game-shape constants (team size, max per slot, taunt charges, synergy threshold, BPM max/start/regen). Editing never mutates the active row — "Save as new version" appends a new row ingame_balance_versionsand (optionally) activates it inside a transaction; the version-history table at the bottom lists every prior cut with creator + timestamp + notes, and a one-click "Activate" button reverts to it. Type-chart editing is intentionally deferred (81 cells; expose via MCP/SQL for now). Notes: changing team size / max-per-slot / synergy threshold also affects band-builder UI and validation, which still read code constants — the editor warns about that in the Game shape section. Routes:GET/POST /api/admin/balance/versions,POST /api/admin/balance/versions/[id]/activate. Page:app/admin/balance/. Validators:lib/balance/validators.ts. -
MCP server: balance tools for agents. The same operations are exposed to the admin MCP server at
/api/mcpfor tokenized agent use:get_active_balance(current snapshot),list_balance_versions,get_balance_version,create_balance_version(with optionalactivate: true), andactivate_balance_version. All admin-gated via the existing bearer-token flow. Useful for letting a tuning agent run experiments without going through the web UI. -
Game balance is now versioned in the database. Every numeric tuning the engine reads — synergy multipliers (metal HP %, hip-hop damage %, pop heal %, folk buff %, electronic shield % / +BPM, goth lifesteal, emo conditional damage, alternative damage, punk cost reduction, metal & punk hearing-damage extra turns), the genre matchup matrix, crit/miss curves, the looper growth exponent, and the default vampiric rate — now lives in a new
game_balance_versionsrow instead of as hardcodedconsts in code. The active row (one at a time, enforced by a partial unique index) drives the engine; admins will be able to edit it, save as a new version, and revert by activating an older row. Each row recordscreated_by(FK touser),created_at, andparent_id, so we get a "who tuned what, when, and from where" audit trail. Structural rules (which side an effect targets, that emo only fires below half HP, that vampiric ignores shields) stay in code — only the numbers move. Engine reads throughgetBalance()(lib/balance/active.ts); server hydrates from the DB at request time, browsers hydrate via a one-shotGET /api/balanceon app mount (app/_components/balance-hydrator.tsx). Fallback at every layer is the code baseline inlib/balance/baseline.ts, which mirrors the shipping defaults — so an unhydrated boot, a 5xx on/api/balance, or a fresh DB with no active row all play the game identically to before this change. Migration0016_game_balance_versions.sql; seed (pnpm db:seed) writes the initial baseline row. No combat numbers shift in this release — this is the plumbing that makes future balance passes safe. -
nopeople battle music. Seeded the four mastered tracks from the Bad Idea release ("That's What I Need", "Bad Idea", "Wide Awake", "Bike the Presidio") as
band_id="nopeople"rows inaudio_tracks. Battle audio (/api/battle/track) now returns a random pick whenever any nopeople member is on stage. WAVs are transcoded to 192 kbps mp3 before upload — keeps each file at 3–7 MB so the<audio>element can start playing without a 40 MB cold download. Reproducible vianpx dotenv -e .env.local -- tsx scripts/seed-nopeople-audio.ts(idempotent; reuses Lee Harrold's self-grant). -
Friends list — see everyone you've battled in PvP. New
/friendspage lists every user you've played a PvP match against, with W/L from your POV, total shows, last-played timestamp, and the band they last brought. Powered live frombattle_recordsviaGROUP BY opponent_user_id(no separate follow graph). PvP battles now stampopponent_user_idon the row (migration0017_friend_opponents) so the join intouser.nameworks. AI/canned battles leave it null. Reachable from the global ☰ menu and from the "Challenge a friend" CTA on the empty state. Seed adds a second user (friend@local.test) and two PvP rows so the page is non-empty out of the box. Files:app/friends/page.tsx,app/api/player/friends/route.ts,lib/player-data/server.ts#listFriends. -
Past Battles view — every show with both rosters as clickable emblems. New
/battlespage lists every battle this player has played, newest first. Each card has a top stripe showingWin/Lossin green/red, the game mode (vs AI / PvP / Ranked), and a timestamp. Below that, both bands sit side-by-side with their lineups rendered as small zine-style emblems — emoji glyph + first-name mono label — and every emblem is aLinkto that musician's/p/[slug]profile. AI/canned opponents resolve their full 5-up lineup viaENEMY_BANDS; PvP rows don't yet snapshot the opposing roster on the battle row, so they render dashed "?" placeholders until that follow-up lands. Header tally shows total W/L. Reachable from the new global ☰ menu as 📓 Battles. Files:app/battles/page.tsx. -
Global "☰ Menu" puck on every page. Companion to the corner Feedback tab — pinned bottom-left, opens a zine-styled menu listing every top-level destination (Game, Catalog, Battles, Tutorial, Rules, Scan, Account, Admin). Active route is highlighted; closes on link click, Escape, or backdrop tap. Hidden on
/intake/*so kiosk operators stay in the form. Files:app/_components/global-nav.tsx, mounted inapp/layout.tsx. -
Rhythm now owns BPM (formerly "speed points"); player-facing copy renamed throughout. Codified the role design rule that rhythm/melody/harmony each own one combat resource — rhythm = BPM, melody = enemy HP, harmony = ally amplification (with
healas the deliberate exception). New design doc atlib/moves/CLAUDE.md. Mechanical changes:speed-boostmoved from support → tank inlib/moves/kind.ts#KIND_TO_CLASS; Synth Sam re-slotted from harmony → rhythm (his "BPM Up" special is the slot's flagship move) with HP bumped 95→130 to fit a tank stat line; Viola Vega's "Sync Pulse" reworked from+1 BPM(speed-boost) to+20 dmg buff(buff-damage) so she stays a harmony character and Final Encore keeps its hip-hop synergy intact. Neon Nocturnes shifted frombongo-bay→looper-louso its slot counts stay legal with Synth Sam now occupying a rhythm seat. Player-facing strings — fighter-stage's pip meter, the bottom-panel attacker readout, team-focus, every move card across catalog/team-builder/admin/tutorial/spotlight/preview, and tutorial dialog — now read BPM instead of SP / speed points. Internal identifiers (speedfield,MAX_SPEED, kind stringsspeed-boost/redistribute-sp) intentionally kept their legacy names to avoid a DB migration. -
Crits and misses, with instrument-specific flair animations. Damage-flavored moves (
damage,looper,vampiric,aoe-damage) now roll for crit (base 10%, ×1.5 damage; +5% on super-effective and on DPS klass, −4% on resisted; capped at 50%). Single-target damage moves also roll for miss (base 8%; +5% on resisted, −3% on super-effective or when the attacker is already buffed; capped at 30%). AoE skips miss — one swing across 3+ targets shouldn't whiff entirely — but still rolls a single crit for the whole swing. Every crit and miss pops a per-instrument animation on the attacker's tile, driven offCharacter.title: drummer crit throws both drumsticks in opposite arcs, guitarist crit snaps a string with~~SNAP~~, bassist crit BWOMPs the cab, vocalist crit mic-drops, keys cascade ♪♬♩, strings/wind bow-snap ╱╱╱, with a default ⚡ starburst for anything we can't classify. Misses get their own glyphs too: drummer tumbles a stick, guitarist's strap slips, bass cable unplugs, vocalist gets a~crack~, keys hit a ♭, strings/wind~SQUEAK~, default 💨 puff. Each flair floats a CRIT! or miss… banner above the fighter. Math:lib/battle/engine.ts(withinstrumentFromTitleinlib/game.ts). Visuals: newflairResolveEffect kind, rendered byapp/_components/battle/flair-overlay.tsxwith keyframes inapp/globals.css. -
Character spotlight: Maestro's tour for any bandmate. New route
/tutorial/spotlight/[characterId]runs a short Maestro-Magnus-narrated mini-tutorial — intro → reveal card → plain-words explanation of the SPECIAL → scripted 1v1 demo battle where the character auto-fires BASIC then SPECIAL against a softened Daphne, with Maestro narrating each step → outro. Built so any new character added toROSTERgets a "here's how they play" walkthrough for free; the explanation text is generated per-move-kind so it stays accurate as new specials ship. Entry point: a▶ Maestro's tourbutton on the catalog character detail (/catalog → tap a character), alongside the SIGNATURE row. Files:lib/tutorial-spotlight.ts(scripts + per-move explanations + demo timeline),app/tutorial/spotlight/[characterId]/page.tsx(self-contained client flow reusing the sharedresolveMovebattle engine). -
Admin: moves catalog (CRUD + search + class sort). New
/admin/movespage lists every canonical move with a search box (matches name + description), a kind filter, a class filter (tank/dps/support — class is derived from kind vialib/moves/kind.ts#KIND_TO_CLASS), and a sort selector (class / name / kind / cost). Class chips, parameters, and SP cost are rendered per row./admin/moves/newand/admin/moves/[id]/edituse a sharedMoveFormthat only shows the numeric fields each kind actually uses (perKIND_FIELDS), and surface 409s on duplicate names with a deep-link to "edit the existing one". Backed by a newmovestable (lib/db/migrations/0015_moves.sql) withUNIQUE(name)enforcing the working rule "same effect = same name". REST surface:GET/POST /api/admin/movesandGET/PATCH/DELETE /api/admin/moves/[id]. The catalog is seeded fromROSTERonpnpm db:seed(40 distinct moves across all 16 kinds) — re-runs are idempotent viaON CONFLICT (name) DO NOTHING, so admin edits are preserved across seed. -
In-app feedback for alpha. A 💬 Feedback tab is pinned to the corner of every page; clicking it opens a quick form that stamps the current path, server-side OTel trace id, and (when present) Sentry replay id onto a row in the new
user_feedbacktable. The result screen also surfaces a "How was that show?" prompt that attaches the full battle log + opponent/band metadata as context. Every submission mirrors to Sentry's Feedback inbox so alpha notes show up next to any captured replay. -
Admin: alpha feedback inbox + battle-log viewer. New
/admin/feedbackpage (sidebar link + dashboard stat card) lists everyuser_feedbackrow with category filters (general / post-battle / bug / idea). Expanding a row shows the full message, attached context, and — for post-battle submissions — the in-game battle log line-by-line in a monospace pane. Trace ids link to Honeycomb and replay ids link to Sentry (setNEXT_PUBLIC_HONEYCOMB_TRACE_URL/NEXT_PUBLIC_SENTRY_REPLAY_URLto enable). Backed byGET /api/admin/feedback?category=…, gated byrequireAdminApi(). Counts on the overview pull in the same data. -
Admin: appearances index + full move palette in character form. New
/admin/appearanceslands a grouped-by-event view of every band appearance so admins can jump straight to "Add character" without drilling through events first.VersionFormnow exposes the rhythm/melody specials added in 0.3.x —looper,aoe-shield,redistribute-sp,clipping— so admin-created characters can use any move kind in the engine, not just the pre-0.3 subset.
From playtester feedback — audit pass against the open feedback list.
-
Starter band now ships all 5 nopeople members, with Andrew as the 5th. The seed-starter route was handing new accounts a 4/5 band (
dan, dax, lee, brant). Renamed the placeholder pop-harmony / Backing Vox character from "Ramona" → Andrew (lib/game.ts) and added"andrew"toSTARTER_MEMBER_IDS/STARTER_SEEN_IDSinapp/api/player/seed-starter/route.tsso the first-load lineup matches the real band. The canonicalnopeopleentry inENEMY_BANDSalso points at the new id. No cutout art for Andrew yet — falls back to the 🎤 emoji until a portrait is shot. -
Type and move emojis removed to stop colliding with character emojis. Playtesters reported that genre/class emojis (🤘 🧷 💖 🌾 ⚡ 🎧 🦇 💔 / 🛡 ⚔ ✨) and move-descriptor emojis (🛡 🤫 🔁 🎶❌ 🩸 🥁 📈) overlapped with individual character emojis (🥁 Devin, 🎸 Dan/Dax, etc.), making it hard to tell roster icons apart from type/move icons. Cleared the
emojifield on every entry ofGENRE_INFOandCLASS_INFOinlib/game.ts, swapped the affected chips/badges (fighter-stage, opponent-select, intake header, team-builder synergy strip, tutorial fighter chip,pills.tsx#TypePill) to render the genre label instead of an empty span, and stripped leading emojis from every line ofmoveDescriptorinlib/format.ts(taunt,mute,feedback,detune,hearing-damage,redistribute-sp,aoe-shield,looper,clipping). Updatedcontent/rules.md(Classes table + Genres-by-Family table) to match. In-combat status icons (taunt/mute/feedback/shield inphase-row.tsx) intentionally kept their emoji glyphs — those are transient state indicators, not types or moves. -
PvP: turn-flip notifications when you've tabbed away. Players couldn't tell when an opponent had moved in a backgrounded tab. Two-layer fix in
app/_components/battle/use-turn-notifications.ts, wired intopvp-battle.tsxagainst the existingTurnResolvedevent. Always-on title flash: when the turn flips to you whiledocument.hidden, the tab title becomes "⚡ Your turn — Aux"; restored onvisibilitychangeor when the turn flips away. Opt-in louder layer behind a new🔔/🔕toggle in the battle header (persisted tolocalStorageunderaux.pvp.notifyOnTurn): plays a short two-tone Web Audio chime, callsnavigator.vibrate(80), and — ifNotification.permission === "granted"and the tab is hidden — fires a system toast taggedaux-pvp-turnwhose click brings the tab forward. Flipping the toggle on for the first time triggersNotification.requestPermission(); declining still leaves the chime + vibration working. Exit is unchanged — the✕ Exitchip in the battle header was already always-clickable, so players can leave a match at any point in either AI or PvP mode.
0.3.2 — 2026-05-20
From playtester feedback (round of friends-of-friends notes). Every item below was raised by a playtester; this is the audit-and-fix pass.
- Shield UI is now light blue (
#7CC6E8) on the HP bar and the🛡 Nchip, so a healthy fighter's shield reads against the harmony-green HP fill instead of blending into it. (app/_components/battle/fighter-stage.tsx) - Damage / heal / SP numbers got bigger and stay longer — 26pt, 1.6 s float-up — so they don't blink out before you can read them. (floating-hit lifetime in
app/_components/battle/battle.tsx; type styles infighter-stage.tsx) - Band names sit above each half of the battlefield with the active side highlighted in ink, so it's obvious at a glance which column belongs to which band. (
BandLabelinapp/_components/battle/battle.tsx) - Rules link in battle opens in a new tab (
target="_blank") so consulting the rules no longer dumps you out of the current battle. - AI / auto-battle no longer drains everyone's SP at once. The applyAction path was reading stale
players/enemiesfrom closure on AI turns; SP-regen ticks now flow throughstateOverrideso only the actual attacker pays SP. - Type Advantages modal title is now readable — explicit
text-whiteon the heading (was black-on-black), and inner clicks dismiss the modal so the on-screen "tap anywhere to dismiss" copy matches behavior. - Click another ally to redirect a support ability — once an attacker + heal/buff/shield is selected, tapping a different bandmate now retargets and fires without falling back to the cycle arrows. (
handlePlayerColumnClickinapp/_components/battle/battle.tsx) - "Build a band" lives on the home menu. New title-screen CTA routes through the team-builder with
buildOnly=trueand returns you to the bands picker — you no longer have to start a battle to author a deck. (startCreateFromHomeinapp/page.tsx) - Emo synergy reworked. Was a dead "+1 turn hearing damage" — no emo character dealt hearing damage. Now: band-wide +25% damage when the attacker is below half HP ("sad and angry"). The hearing-damage duration bonus moved to the loud family (Metal and Punk synergies), matching where the loud-flavored Tinnitus actually lives.
0.3.1 — 2026-05-13
Vampiric tuning — shields don't bleed, and the drain is no longer free 1:1.
- Vampiric only drains HP damage. When the target has a shield, the shield absorbs the hit first; only damage that actually reaches HP counts toward the lifesteal. Hitting a shielded enemy yields zero heal until you break through.
- Vampiric heal is now percentage-based. Lifesteal returns 50% of HP damage dealt (rounded), or 75% with the Goth synergy. Because it's a fraction of the actual HP damage, damage boosts (type effectiveness, buff-damage, hip-hop synergy, etc.) increase the heal proportionally — but never to 1:1. Drain ≤ damage, always.
- Goth synergy reworded: from "+50% heal on vampiric" → "Vampiric lifesteal rate 50% → 75% on goth members".
- Battle log now annotates vampiric hits with shield absorption ("shield ate N") and total HP drained.
0.3.0 — 2026-05-13
New family, new genres, two new specials.
- New family: Edgy (genres: Goth 🦇 and Emo 💔). Roster grows from 18 → 25 with 7 new characters: Bones Bri, Echo Eli, Wail Wendy, Pining Pete, Crow Cara, Mope Mara, plus one new loud-genre DPS (Ringer Reagan).
- New 4-family type chart replacing the old 3-family cycle:
- Loud ×1.5 vs Acoustic, Edgy · ×0.66 vs Digital
- Acoustic ×1.5 vs Digital, Edgy · ×0.66 vs Loud
- Edgy ×1.5 vs Loud, Digital · ×0.66 vs Acoustic
- Digital ×1.5 vs Loud, Acoustic · ×0.66 vs Edgy
- New move kind: Vampiric (DPS, edgy flavor). Single-target damage that heals the attacker by the damage dealt. Type multipliers scale both the damage and the steal. Goth synergy buffs the heal portion +50%.
- New move kind: Hearing damage (DPS, loud flavor). Applies a damage-over-time status that ticks at the start of the affected character's own turn for several turns. Any heal applied to that character cures it. Emo synergy extends the duration by 1 turn (yes, an emo support packs a meta-synergy).
- New enemy band: The Crypt Society — goth-synergy lineup featuring vampiric and hearing damage so you can taste them as a foe.
- Two new synergies: Goth (3+) buffs vampiric heal, Emo (3+) extends hearing damage duration.
- Rules page updated with the new type chart and special-move descriptions.
0.2.2 — 2026-05-13
- The changelog now renders on
/rulesdirectly below the rules. Every iteration's notes are visible without leaving the game. - Added a "Rules / Changelog" jump-nav at the top of the page.
0.2.1 — 2026-05-13
From playtester feedback ("every turn I was able to use 2-3 specials which never gave a reason to not use them" + "building debuffs basically protects you from the enemy ever getting hurt and steals all of the buffs an enemy support offers"):
- Speed cap raised from 5 → 10. Starting SP 2 → 3. Regen unchanged at +1 / own turn.
- Special costs raised across the board so specials feel like commitments rather than free actions every turn:
- Cheap supports (Folk Chord, Campfire Song, Sync Pulse): 1 → 2 SP
- Heavy supports (Backup Chorus, BPM Up, Bit Crush): 2 → 3 SP
- All tank specials (taunts, shields): 2 → 3 SP
- All DPS specials (AoE, Mute, Feedback, Detune): 3 → 4 SP
- Detune no longer steals enemy support buffs. It now triggers only on damage moves (basic or AoE), redirecting them to the affected character's own ally. Heal / Buff / Speed-boost / Shield resolve normally while detune sits armed.
- One debuff per target. Mute, Feedback, and Detune no longer stack on the same enemy; casting a second debuff on an already-debuffed target fizzles (SP still spent). Mute now hides on a random clean foe; if every foe is debuffed, Mute fizzles.
0.2.0 — 2026-05-13
First versioned release under the Aux: the Gathering name.
Combat
- Replaced discrete cooldowns with a speed point (SP) system (cap 5, start 2, regen +1 per own turn).
- Every character now has a basic attack (1 SP, 18 dmg, genre-flavored name) in addition to a class-specific special.
- DPS specials reworked to debuffs + AoE: Mute (hidden, random foe), Feedback (next damage backfires), Detune (next move flips sides), and AoE damage.
- Tank specials restricted to Taunt (locked stance, consumed by next opposing damage) and Shield (temp HP layer absorbed before HP).
- Support specials unchanged in role (heal, buff-damage, speed-boost) but speed-boost now adds SP.
Bands & types
- 18-character roster across 6 genres (Metal, Punk, Pop, Folk, Electronic, Hip-hop) and 3 families (Loud, Acoustic, Digital).
- Pokémon-style type chart: Loud › Acoustic › Digital › Loud, with ×1.5 / ×0.66 multipliers on damage and AoE.
- Genre synergies activate at 3+ same-genre band members (HP boost, cost reduction, heal/buff/speed/shield/damage modifiers).
- Slot determines class: Rhythm → Tank, Melody → DPS, Harmony → Support. Max 2 per slot → every band runs 2/2/1.
Site
- Rebranded from "Battle of the Bands" to Aux: the Gathering.
- New
/rulespage rendered fromcontent/rules.md. - Mobile-first responsive pass on the battle screen and team builder.
0.1.0 — initial
- Scaffolded Next.js project, basic JRPG combat loop, team selection, manual and auto battle modes.