From 1af55291bb19c9e0addbb5c4dfa91864481dab25 Mon Sep 17 00:00:00 2001 From: 0xward <0xward.dev@gmail.com> Date: Sun, 2 Aug 2026 17:07:56 +0000 Subject: [PATCH] The half second of map that should not have been there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWNER: *"terus kejar yg 0.5 detik itu."* Outside MiniPay the world map was visible under the gate for about half a second, and no amount of React could fix it: the map is in the PRERENDERED HTML, so it paints before a single line of JavaScript has run. That is deliberate and worth keeping — it is what /game's LCP optimisation buys, and MiniPay players, who never see the gate, keep the whole benefit. So the fix is the only thing that can run earlier than React: an inline script in the document. When the gate is certain to appear it marks before the first paint and CSS hides the home screen; when it is not, it does nothing at all and the map paints exactly as it does today. It marks rather than appending an overlay, and that was measured, not assumed. The overlay version was written first and Next's hydration — which reconciles the children of — deleted the node at 261ms, roughly 600ms before the gate was ready to cover anything. An attribute React never rendered is not part of that reconciliation and survives. Hiding by `visibility` rather than removing: the map's image request stays intact, so a player who skips the gate gets the map immediately instead of paying for it twice. Measured after the change, polling every 30ms for 5s: inside MiniPay no mark, home visible at 102ms (untouched) new player outside mark 144→509ms, home NEVER visible, gate 547ms returning (skipped once) no mark, home visible at 95ms (no black wait) This is a THIRD copy of the MiniPay rule, and the cost was accepted knowingly: every branch in the script can only decide "do not veil", so a drift between the copies costs a non-MiniPay player half a second of map and can never reach a MiniPay player at all. The 3s dead-man timer matters as much — if React never arrives, the mark lifts by itself rather than leaving somebody on a black screen. Verified: tsc clean, build unchanged at 142/242 kB, 41 assertions in test:privy including one that runs in CI on the default path, plus test:streak-ui, test:season-ui, test:pass-cards, check:copy, check:cssvars, check:market and audit. Lint 35. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ --- app/game/layout.tsx | 51 +++++++++++++++++++++++++++++ components/game/GameFlowManager.tsx | 12 ++++++- components/game/MainMenu.tsx | 2 +- components/game/PrivyGate.tsx | 8 ++++- components/game/WorldMapHub.tsx | 2 +- lib/privyGateFlag.ts | 16 +++++++++ scripts/test-privy-gate.js | 40 ++++++++++++++++++++++ styles/game.css | 18 ++++++++++ 8 files changed, 145 insertions(+), 4 deletions(-) diff --git a/app/game/layout.tsx b/app/game/layout.tsx index 50f409e..adc09c6 100644 --- a/app/game/layout.tsx +++ b/app/game/layout.tsx @@ -16,9 +16,60 @@ import '../../styles/game.css' // itself is already using (see lib/useContractPlayer.ts). // · The Celo RPC is only reached after wagmi mounts on idle, which is well // after the map has painted — nothing waiting on it is being waited for. +// ─── The veil: 0.5s of world map that should not have been there ──────────── +// +// OWNER: *"terus kejar yg 0.5 detik itu."* +// +// Outside MiniPay the map was visible under the gate for about half a second, +// and no amount of React could fix it: the map is in the PRERENDERED HTML, so +// it paints before a single line of JavaScript has run. That is deliberate and +// worth keeping — it is what /game's LCP optimisation buys, and MiniPay players +// (who never see the gate) get the whole benefit. +// +// So the fix is the only thing that can run earlier than React: an inline +// script in the document itself. If the gate is going to appear, it marks the +// document before the first paint and CSS hides the home screen. If it is not, +// it does nothing at all and the map paints exactly as it does today. +// +// It marks rather than appending an overlay, and that is not a style +// preference — the overlay version was written and measured first. Next's +// hydration reconciles the children of and threw the node away at 261ms, +// long before the gate was ready to cover anything, so the map was on screen +// from 323ms to 882ms. An attribute React never rendered is not part of that +// reconciliation and survives. +// +// ── THIS IS A THIRD COPY OF THE MINIPAY RULE, AND THAT WAS A DECISION ─────── +// +// The rule already lives in shouldOfferSignIn() and in privyGateFlag. Copying +// it again is a real cost, accepted because the copy is ONE-WAY: every branch +// below can only decide "do not veil". A drift between the copies costs a +// non-MiniPay player half a second of map — never the reverse, and MiniPay +// cannot be reached by it at all. +// +// The dead-man's timer matters as much as the conditions: if React never +// arrives — a chunk 404, a JS error — the sheet lifts by itself after 3s rather +// than leaving somebody staring at a black screen forever. +const GATE_VEIL = `(function(){try{ + if(window.ethereum&&window.ethereum.isMiniPay)return; + var q=null;try{q=new URLSearchParams(location.search).get('privy')}catch(e){} + if(q==='0')return; + if(!__APP_ID__)return; + if(q!=='1'&&__ENV_ON__!=='1')return; + if(localStorage.getItem('nullstate-auth-address'))return; + if(localStorage.getItem('nullstate-signin-skipped')==='1')return; + var h=document.documentElement; + h.setAttribute('data-ns-gate','1'); + setTimeout(function(){h.removeAttribute('data-ns-gate')},3000); +}catch(e){}})();` + export default function GameLayout({ children }: { children: React.ReactNode }) { + const veil = GATE_VEIL + .replace('__APP_ID__', JSON.stringify(process.env.NEXT_PUBLIC_PRIVY_APP_ID || '')) + .replace('__ENV_ON__', JSON.stringify(process.env.NEXT_PUBLIC_PRIVY_GATE || '')) return ( + {/* Before {children}, so it runs before the map markup is even parsed. */} +