<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MeetAndPlay</title>
    <description>The latest articles on DEV Community by MeetAndPlay (@meetandplay).</description>
    <link>https://dev.to/meetandplay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fthepracticaldev.s3.amazonaws.com%2Fi%2F99mvlsfu5tfj9m7ku25d.png</url>
      <title>DEV Community: MeetAndPlay</title>
      <link>https://dev.to/meetandplay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/meetandplay"/>
    <language>en</language>
    <item>
      <title>Building a free embeddable widget that earns attribution honestly: iframe + credit line design</title>
      <dc:creator>MeetAndPlay</dc:creator>
      <pubDate>Thu, 05 Nov 2026 23:56:15 +0000</pubDate>
      <link>https://dev.to/meetandplay/building-a-free-embeddable-widget-that-earns-attribution-honestly-iframe-credit-line-design-56dl</link>
      <guid>https://dev.to/meetandplay/building-a-free-embeddable-widget-that-earns-attribution-honestly-iframe-credit-line-design-56dl</guid>
      <description>&lt;p&gt;We ship small embeddable widgets (a club availability view, a court-cost calculator, a data chart) that other sites can put on a page. This post is about the design decisions: how the iframe is locked down, how it resizes without hand-tuning, and how the credit line is built so the attribution is visible, optional to remove, and not a trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two pieces: a loader and an embed route
&lt;/h2&gt;

&lt;p&gt;The host page adds a script and a placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://meetandplay.mx/embed.js"&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-meetandplay-club=&lt;/span&gt;&lt;span class="s"&gt;"club-slug"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script finds each placeholder, builds an iframe pointing at a dedicated &lt;code&gt;/embed/...&lt;/code&gt; route on our domain, and inserts it. Options come from &lt;code&gt;data-*&lt;/code&gt; attributes (&lt;code&gt;data-locale&lt;/code&gt;, &lt;code&gt;data-accent&lt;/code&gt;, &lt;code&gt;data-bg&lt;/code&gt;, &lt;code&gt;data-sport&lt;/code&gt;) and every one is re-validated in the loader and again on the server, since a data attribute is user input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(?:[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{3}&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{6})&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCALE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]{2,3}(?:&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&lt;/span&gt;&lt;span class="se"&gt;]{2,8})?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSrc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-accent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-locale&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es-MX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCALE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es-MX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/embed/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/club/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Make only the embed routes frameable
&lt;/h2&gt;

&lt;p&gt;The easy mistake is to relax framing for the whole site. Instead, everything except &lt;code&gt;/embed/*&lt;/code&gt; gets &lt;code&gt;X-Frame-Options: SAMEORIGIN&lt;/code&gt; and &lt;code&gt;frame-ancestors 'self'&lt;/code&gt;, and the embed group replaces the CSP with one that allows any ancestor. In Next.js that is two header rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EMBED_SOURCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/embed/:path*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// every path that is NOT under /embed&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FRAME_GUARD_SOURCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/:path((?!embed(?:/|$)).*)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;X-Frame-Options&lt;/code&gt; has no "allow all" value, so you cannot override it away on the embed routes; they must be excluded from the group that sets it. Allowing any ancestor is only reasonable because the widget holds nothing worth clickjacking: it shows public data, has no login or payment form, and links open the full app in a new tab (&lt;code&gt;target="_blank"&lt;/code&gt; with &lt;code&gt;rel="noopener"&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;We also send &lt;code&gt;X-Robots-Tag: noindex, nofollow&lt;/code&gt; on the embed routes. The framed page is a fragment of someone else's page, not a result in its own right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-resizing with postMessage
&lt;/h2&gt;

&lt;p&gt;Hand-tuned iframe heights break the moment content changes. The embed page reports its height; the loader applies it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside the embed page&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meetandplay:height&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ResizeObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in the loader&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// only our origin&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meetandplay:height&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentWindow&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// only frames we created&lt;/span&gt;
      &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details: the target origin &lt;code&gt;"*"&lt;/code&gt; on the sender is acceptable because the payload is one number about a public page, and the receiver checks both &lt;code&gt;event.origin&lt;/code&gt; and that &lt;code&gt;event.source&lt;/code&gt; is one of its own frames, then clamps the value so a spoofed message cannot make the host layout absurd.&lt;/p&gt;

&lt;h2&gt;
  
  
  The credit line
&lt;/h2&gt;

&lt;p&gt;An embed that gives the host something useful can reasonably ask for a credit. Rules we set ourselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visible.&lt;/strong&gt; A normal caption under the widget, not hidden, not tiny, not off-screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brand-only anchor text.&lt;/strong&gt; The link text is the product name, not a keyword phrase. Keyword-stuffed anchors are exactly what makes embed credits look like link schemes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plain link.&lt;/strong&gt; It is a normal link with a &lt;code&gt;utm_source&lt;/code&gt; parameter, so the target site can see where visits come from. No cloaking, no redirects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where it lives.&lt;/strong&gt; The snippet we give people includes the credit inside the placeholder div. The loader inserts the iframe &lt;em&gt;above&lt;/em&gt; existing content, so the line reads as a caption:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// host.insertBefore(frame, host.firstChild)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-meetandplay-club=&lt;/span&gt;&lt;span class="s"&gt;"club-slug"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://meetandplay.mx/?utm_source=embed"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;MeetAndPlay&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A side benefit of this design: with JavaScript disabled or the script blocked, the placeholder still renders a useful link rather than an empty box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I would check before copying this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't put anything stateful in a frameable route. Login, payments, forms with side effects: keep them out and open the main app in a new tab.&lt;/li&gt;
&lt;li&gt;Keep the loader dependency-free and wrapped in an IIFE so it leaks no globals onto the host page.&lt;/li&gt;
&lt;li&gt;Clamp every number you accept from &lt;code&gt;postMessage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Be willing to be ignored. If someone removes the credit, the widget should still work. Attribution that has to be forced is not worth having.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is small enough to reuse for any read-only widget. If you want to see it in action, the club availability widget from &lt;a href="https://meetandplay.mx" rel="noopener noreferrer"&gt;MeetAndPlay&lt;/a&gt; uses exactly this loader.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Scheduling a padel americana: a rotation algorithm that lets everyone partner everyone</title>
      <dc:creator>MeetAndPlay</dc:creator>
      <pubDate>Thu, 05 Nov 2026 23:55:59 +0000</pubDate>
      <link>https://dev.to/meetandplay/scheduling-a-padel-americana-a-rotation-algorithm-that-lets-everyone-partner-everyone-2k88</link>
      <guid>https://dev.to/meetandplay/scheduling-a-padel-americana-a-rotation-algorithm-that-lets-everyone-partner-everyone-2k88</guid>
      <description>&lt;p&gt;An americana is a padel social format: a group of players turns up, and for each round the pairs change so that you play &lt;em&gt;with&lt;/em&gt; as many different people as possible. The scheduling problem sounds trivial until you try it with 7 players and 2 courts. This post walks through the algorithm we use in a TypeScript domain package, what it guarantees, and where it deliberately does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: partners come from the circle method
&lt;/h2&gt;

&lt;p&gt;Round-robin tournaments use the "circle method": pin one slot, rotate the others one seat per round, and pair each slot with the one opposite. For an even number &lt;code&gt;m&lt;/code&gt; of slots, over &lt;code&gt;m - 1&lt;/code&gt; rounds every unordered pair appears exactly once.&lt;/p&gt;

&lt;p&gt;An americana needs the same property, but applied to &lt;em&gt;partners&lt;/em&gt; instead of opponents. If the pairs from each round are used as that round's &lt;strong&gt;partnerships&lt;/strong&gt;, then across a full rotation every player partners every other player exactly once. That is a 1-factorization of the complete graph, and it gives the main guarantee of the format for free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Pin slot 0, rotate the rest by (round - 1) places.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nx"&gt;round&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rotationPairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;half&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;front&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;half&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;half&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;front&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;rotationPairs&lt;/code&gt; for rounds &lt;code&gt;1..n-1&lt;/code&gt; with 8 players gives 7 rounds of 4 partnerships each, 28 distinct pairs in total, which is every pair of 8 players.&lt;/p&gt;

&lt;h2&gt;
  
  
  Odd counts: pad with a bye, and pad in the pinned seat
&lt;/h2&gt;

&lt;p&gt;With an odd number of players you add a &lt;code&gt;null&lt;/code&gt; slot so the rotation has an even size. Where you put it matters. The pinned slot's opposite partner changes every round, so putting the &lt;code&gt;null&lt;/code&gt; in the pinned position makes the sit-out walk through every real player in turn. Put it anywhere else and the pinned player sits out every single round.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;padToEven&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;Slot&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// A partnership that contains null is a bye for the other player.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;partnerships&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sittingOut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;rotationPairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;padToEven&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;sittingOut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sittingOut&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rotation length is therefore &lt;code&gt;n - 1&lt;/code&gt; for even &lt;code&gt;n&lt;/code&gt; and &lt;code&gt;n&lt;/code&gt; for odd &lt;code&gt;n&lt;/code&gt; (the padded field has &lt;code&gt;n + 1&lt;/code&gt; slots). After that the schedule wraps and round &lt;code&gt;k + length&lt;/code&gt; repeats round &lt;code&gt;k&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rotationLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Courts: fewer courts than pairs
&lt;/h2&gt;

&lt;p&gt;Pairs are not matches. Two pairs make a match, so 8 players give 4 pairs and 2 matches. If the venue only has 1 court, one match is played and the other two pairs rest.&lt;/p&gt;

&lt;p&gt;A pair rests as a unit, so we pick which ones by accumulated bye load: the pairs whose members have sat out the fewest rounds rest next. A seeded random value breaks ties so the same low-index pair is not chosen every round.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;splitByCourts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][],&lt;/span&gt;
  &lt;span class="nx"&gt;byeLoad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;courts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pooled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playingCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;courts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;restCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;playingCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;restCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pooled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pooled&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byeLoad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byeLoad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;tie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tie&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tie&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resting&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;restCount&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pooled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;resting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="na"&gt;resting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pooled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;resting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the honest consequence: because pairs rest together, byes are balanced greedily but not exactly. In our implementation a field of 7 over a full rotation ends up between 2 and 4 byes per player rather than 3 each. Making it exact needs lookahead over the whole schedule and costs partner diversity, which is the thing the format exists for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opponents: greedy, and honest about it
&lt;/h2&gt;

&lt;p&gt;Partners are fixed by the rotation. Who plays whom is a second, softer problem. We match the round's partnerships onto courts greedily: take the first pair, then choose the remaining pair whose four players have met the fewest times so far.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pairKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;meetingCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;away&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;meetings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;away&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;meetings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pairKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not optimal, because committing pair by pair can strand a repeat for the last court. Measured on our implementation, a full 8-player rotation covers 26 of the 28 possible opponent pairings, and a 12-player one covers 62 of 66. A schedule where everyone meets everyone equally often as partner &lt;em&gt;and&lt;/em&gt; as opponent only exists for particular &lt;code&gt;n&lt;/code&gt;, so we do not promise it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism
&lt;/h2&gt;

&lt;p&gt;The round is a pure function of &lt;code&gt;(playerIds, roundNumber, seed, previousRounds)&lt;/code&gt;: no clock and no &lt;code&gt;Math.random&lt;/code&gt;. That means you can regenerate round 4 on its own, print the same sheet twice, or replay a disputed night. Stats are then just counting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maxPartnerRepeats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;fixtures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sideA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nl"&gt;sideB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="p"&gt;}[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixtures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;side&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sideA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sideB&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pairKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;side&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
          &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within one full rotation this returns 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the field changes mid-event
&lt;/h2&gt;

&lt;p&gt;The circle-method guarantee assumes a fixed set of players. A late arrival or an early leaver breaks it. We detect that by comparing the players named in earlier rounds with the current list. If any earlier round was drawn for a different field, we switch to a greedy repair: lowest bye count rests, and partnerships are built by minimising repeats against the partner history. It does not restore the exactly-once guarantee, but it avoids repeats a repeat-free pairing could avoid. One bug worth knowing about: our first version switched back to the circle method as soon as the field looked complete again, which re-paired people who had already partnered earlier in the night. Staying on the repair for the rest of the event fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No level balancing: the rotation reads no ratings. If you want matched teams, you need a different pairing mode.&lt;/li&gt;
&lt;li&gt;No moving winners up courts. That is the &lt;em&gt;mexicano&lt;/em&gt; format, which depends on results.&lt;/li&gt;
&lt;li&gt;Doubles only. A singles "americana" is just a round robin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it without writing code
&lt;/h2&gt;

&lt;p&gt;If you just need a sheet for tonight, we put this algorithm behind a free tool: &lt;a href="https://meetandplay.mx/herramientas/generador-americana" rel="noopener noreferrer"&gt;the americana generator&lt;/a&gt; takes a player list and number of courts and prints the rounds.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>typescript</category>
      <category>sports</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a nationwide court directory for Mexico from OpenStreetMap</title>
      <dc:creator>MeetAndPlay</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:57:44 +0000</pubDate>
      <link>https://dev.to/meetandplay/building-a-nationwide-court-directory-for-mexico-from-openstreetmap-159j</link>
      <guid>https://dev.to/meetandplay/building-a-nationwide-court-directory-for-mexico-from-openstreetmap-159j</guid>
      <description>&lt;p&gt;&lt;em&gt;We needed a list of every place to play padel, tennis and pickleball in Mexico. Nobody sells a good one, so we built it from OpenStreetMap, and learned that an open map is a mirror of the people who edited it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MeetAndPlay is a padel, tennis and pickleball app that launches in Cancún on 1 December 2026. An app that helps people find a game needs to know where the courts are, in a country with 32 states and a lot of places that never made it into a booking platform. We built a nationwide directory, the &lt;a href="https://meetandplay.mx/canchas" rel="noopener noreferrer"&gt;court directory at meetandplay.mx/canchas&lt;/a&gt;, on top of OpenStreetMap. This post covers the pipeline, the licence, and the quality problems we found, with the real numbers from our latest snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OpenStreetMap
&lt;/h2&gt;

&lt;p&gt;The alternatives were scraping a booking marketplace, buying a commercial POI dataset, or using a map provider's places API. We rejected each for a different reason: terms of service, price, and licences that forbid storing and re-publishing the data. OpenStreetMap is the only source that lets you pull the data, keep it, build pages from it, and re-publish what you derive, provided you follow the Open Database Licence (ODbL 1.0) and credit contributors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;The pipeline is a plain script, run by hand when we want a refresh. There is no scheduler and no live dependency on the Overpass API at request time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pull.&lt;/strong&gt; Query the Overpass API for features tagged with a racket sport (&lt;code&gt;sport=tennis&lt;/code&gt;, &lt;code&gt;sport=padel&lt;/code&gt;, &lt;code&gt;sport=pickleball&lt;/code&gt;). Our latest pull has the snapshot timestamp &lt;code&gt;2026-09-25T20:14:17Z&lt;/code&gt;. The timestamp travels with the data, so every page and every number can say how old it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter.&lt;/strong&gt; The most important step. Tennis courts are everywhere in OSM: schools, universities, apartment complexes. We keep facilities that make sense as a place to play and exclude school and university campuses. One filter function is shared by every consumer, because two copies of "what counts as a venue" drift apart within a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locate.&lt;/strong&gt; Assign each place to a state and municipality from OSM's administrative boundaries, matched against INEGI's municipality list. Reverse-geocode a colonia and street with Nominatim, because an OSM point alone is not an address. Compute the distance to a coastline from Natural Earth, which turns out to be useful on a beach-heavy market.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize.&lt;/strong&gt; OSM contact tags are free text edited by anyone. A "website" value can be a &lt;code&gt;javascript:&lt;/code&gt; URL; a "phone" can have no digits. We drop anything that cannot be a safe link at build time, and the render layer checks again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregate.&lt;/strong&gt; Write state and city indexes as generated TypeScript modules. Static data means no database call to render a directory page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule that guided everything: &lt;strong&gt;no invented data.&lt;/strong&gt; If OSM has no name for a place, the page gets no name. We label it from its sport and colonia at render time instead of guessing. That sounds like a small thing and it prevents the whole class of bug where an app confidently shows a made-up club.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the snapshot says
&lt;/h2&gt;

&lt;p&gt;Counting everything in the snapshot, OSM has 1,052 places across all 32 states and 255 cities, carrying a total of 2,307 courts. Only 354 places have a name, a third of the total.&lt;/p&gt;

&lt;p&gt;Per sport, counted as &lt;em&gt;places carrying that sport tag&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sport&lt;/th&gt;
&lt;th&gt;Places in OSM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tennis&lt;/td&gt;
&lt;td&gt;1,005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Padel&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pickleball&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A caution that cost us an embarrassing correction in draft: OSM counts places per sport, not courts per sport. A club with a padel court and two tennis courts is one place in the padel column and one in the tennis column. So our tables say "places with padel", never "padel courts". The 2,307 courts figure is the total across every sport and cannot be split.&lt;/p&gt;

&lt;p&gt;Two results made us rethink the directory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Padel is almost invisible on the open map.&lt;/strong&gt; 51 places in the whole country is far below what the sport's own federation says exists. The International Padel Federation published, in March 2025, "more than 630 clubs" and 2,300 courts for Mexico. Those are their figures, from their research department, and we label them directional: we cannot audit them and we do not divide one number by the other. But the gap exists, and it is the finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancún looks like a tennis town on OSM.&lt;/strong&gt; The map has 35 places and 58 courts in Cancún, all of them tagged tennis, none tagged padel, and none with a name. A city where padel is the headline sport has no padel in OSM. By state, the places with padel are concentrated where a few mappers care: Yucatán 8, Sonora 7, Coahuila 6, Jalisco 5. Quintana Roo, which has 71 places, has 0.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An open map reflects who edits it. That is the lesson to carry to any OSM-derived project: your coverage is a coverage of mappers, not of the world.&lt;/p&gt;

&lt;h2&gt;
  
  
  The licence, in practice
&lt;/h2&gt;

&lt;p&gt;I am not a lawyer and this is not legal advice, but here is how we handled ODbL, which is the thing people get wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attribution&lt;/strong&gt; on every page that uses the data, as "© OpenStreetMap contributors", linked to the licence. No exceptions for a footer you can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share-alike&lt;/strong&gt; applies to derived &lt;em&gt;databases&lt;/em&gt;. Anything we publish as data (our per-state CSV, our dataset markup) is offered under ODbL with the same credit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the licensed data separate from data that isn't.&lt;/strong&gt; We also research venues by hand from public sources, each row citing its source URL. Those rows are &lt;em&gt;not&lt;/em&gt; OSM's and are not ODbL. If we merged them into the same published dataset, we would either relicense them or contaminate the OSM-derived release. So the published report and CSV read &lt;strong&gt;only&lt;/strong&gt; the OSM-derived modules. The directory's own totals, which also count the hand-researched venues, never appear in the ODbL-labelled outputs. In code this is the difference between two exported objects, and a comment in the module saying not to mix them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Publishing the result
&lt;/h2&gt;

&lt;p&gt;Because the dataset is ODbL, we put it out as a &lt;a href="https://meetandplay.mx/reporte-padel-mexico" rel="noopener noreferrer"&gt;report with a downloadable CSV&lt;/a&gt;: per-state places, courts, and places per sport, plus the methodology and the caveats above. The CSV has Spanish snake_case column names and is served from a path with a dot in it on purpose, so our locale-rewriting middleware leaves it alone and it is the same in every language.&lt;/p&gt;

&lt;p&gt;All numbers on the page are derived from the same module that builds the CSV. We never type a figure by hand. If we re-pull OSM, the page, the file and the structured data move together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality problems you will hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates.&lt;/strong&gt; The same club mapped as a point, a polygon and a relation. Dedupe by proximity and name, not by ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong sport tags.&lt;/strong&gt; A tennis court re-lined for pickleball keeps the tennis tag. Do not infer sport counts beyond what is tagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed and abandoned places.&lt;/strong&gt; OSM has lifecycle prefixes, and not everyone uses them. We say clearly that the directory is what mappers recorded, not what any club verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Municipality edges.&lt;/strong&gt; Border points flip between adjacent municipalities in different data vintages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we would tell you to do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Version your snapshot and print its timestamp wherever a number appears.&lt;/li&gt;
&lt;li&gt;Decide what counts as a venue once, in one function.&lt;/li&gt;
&lt;li&gt;Never mix licences in one published file.&lt;/li&gt;
&lt;li&gt;Treat a gap between OSM and an outside source as a feature of your report, not a bug in your pipeline. That is also an invitation: if you play in Cancún and you know where the padel courts are, adding them to OpenStreetMap makes the map better for everyone, not just us.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to poke at the data, the CSV is public, the code is in the &lt;a href="https://github.com/idimetrix/canchas-mexico" rel="noopener noreferrer"&gt;canchas-mexico repo on GitHub&lt;/a&gt;, and we would like to hear what you build from it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Data © OpenStreetMap contributors, ODbL 1.0. Snapshot 2026-09-25T20:14:17Z. External figure: FIP, "Focus en… México", 11 March 2025.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openstreetmap</category>
      <category>opendata</category>
      <category>opensource</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Shipping a sports app in Nahuatl, Maya, Tzeltal and Tzotzil: i18n lessons from 60 locales</title>
      <dc:creator>MeetAndPlay</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:56:35 +0000</pubDate>
      <link>https://dev.to/meetandplay/shipping-a-sports-app-in-nahuatl-maya-tzeltal-and-tzotzil-i18n-lessons-from-60-locales-2nfh</link>
      <guid>https://dev.to/meetandplay/shipping-a-sports-app-in-nahuatl-maya-tzeltal-and-tzotzil-i18n-lessons-from-60-locales-2nfh</guid>
      <description>&lt;p&gt;&lt;em&gt;We build MeetAndPlay, a padel, tennis and pickleball matchmaking and court-booking app that launches in Cancún on 1 December 2026. Here is what we learned adding indigenous Mexican languages to a Next.js app, including the parts that are still unfinished.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When we started, the plan was ordinary: Spanish first, English for tourists, a handful of European languages for the people who fly into Cancún. Then a directive changed the scope: support every language that is actually popular in Mexico. Taken literally, that does not mean French and Dutch. It means Nahuatl, Yucatec Maya, Tzeltal, Tzotzil and a long tail after them.&lt;/p&gt;

&lt;p&gt;The locale list started smaller and has grown to 60 tags today, so treat any number in this post as a moving target. This post is about the engineering and the judgment calls, written for anyone who has to ship a language that your tooling does not know exists. It is also about what we have not done, because that is the part most i18n posts skip.&lt;/p&gt;

&lt;p&gt;If you want to see the result, the &lt;a href="https://meetandplay.mx/en" rel="noopener noreferrer"&gt;MeetAndPlay site&lt;/a&gt; has a language switcher where the indigenous languages sit in their own group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these languages at all
&lt;/h2&gt;

&lt;p&gt;The criterion we ended up with is demographic, and it comes from Mexico's statistics institute. INEGI's 2020 census counted 7,364,645 people aged three and over who speak an indigenous language, across 68 languages. About 866,000 of them (11.8%) do not speak Spanish. Nahuatl has 1,651,958 speakers, Maya (Yucatec) 774,755, Tseltal 589,144 and Tsotsil 550,274.&lt;/p&gt;

&lt;p&gt;For a Cancún product, two of those matter for a reason beyond the national numbers. Yucatec Maya is native to Quintana Roo. And in our own research notes, Tseltal and Tsotsil came out as the second and third most spoken indigenous languages in Cancún after Maya (4,098 and 3,208 speakers in the Benito Juárez municipality), with the highest monolingual rates of the four. Those are Chiapas migrants who work in hospitality, including at sports clubs. For them a Spanish-only screen may be no screen at all.&lt;/p&gt;

&lt;p&gt;We are not claiming these readers are our customers today; a product built for a Mexican city should simply be usable by the people who live in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: locale tags are a permanent public decision
&lt;/h2&gt;

&lt;p&gt;A locale tag ends up in four places you cannot easily change: the URL segment, &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt;, &lt;code&gt;hreflang&lt;/code&gt;, and Open Graph's &lt;code&gt;og:locale&lt;/code&gt;. Once other sites link to &lt;code&gt;/xyz/&lt;/code&gt;, renaming it costs real money. So we audited every tag against two registers, the ISO 639-3 code set and the IANA language subtag registry, before shipping any copy. Three findings, in increasing order of annoyance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some languages map to one code cleanly.&lt;/strong&gt; &lt;code&gt;yua&lt;/code&gt; (Yucatec Maya), &lt;code&gt;tzh&lt;/code&gt; (Tzeltal) and &lt;code&gt;tzo&lt;/code&gt; (Tzotzil) are individual languages with one code each. Easy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nahuatl has no individual code.&lt;/strong&gt; &lt;code&gt;nah&lt;/code&gt; is a &lt;em&gt;collection&lt;/em&gt; code: ISO 639-3 splits Nahuatl into about 29 individual languages and none of them is "Nahuatl". RFC 5646 says a collection subtag may be used when more specific information is unavailable, but that it SHOULD NOT be used for content better tagged with an individual language. We kept &lt;code&gt;nah&lt;/code&gt; anyway, because picking one variety (say, the Huasteca one) would tell a reader from Guerrero that we chose someone else's Nahuatl for them. The cost is real and we document it: a browser sending &lt;code&gt;Accept-Language: nhe&lt;/code&gt; will not match &lt;code&gt;nah&lt;/code&gt;. Content negotiation is where this bites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One of our tags was simply the wrong language.&lt;/strong&gt; &lt;code&gt;mix&lt;/code&gt; looks like "Mixtec" but ISO 639-3 defines it as Mixtepec Mixtec, the speech of one municipality in Oaxaca, with on the order of ten thousand speakers. Mixtec as a whole has over half a million speakers in the census and 52 individual codes with no macrolanguage above them. If we ever served &lt;code&gt;&amp;lt;html lang="mix"&amp;gt;&lt;/code&gt; for a translated Mixtec site, we would tell screen readers and search engines something false about roughly 98% of the intended readers. It is still open as a decision; the lesson is that a tag that "looks right" is not a tag that has been checked.&lt;/p&gt;

&lt;p&gt;A smaller one: ISO prints "Tzeltal" and "Tzotzil"; the modern INALI orthography is "Tseltal" and "Tsotsil". We show the latter in the switcher and keep the ISO codes in the markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: your date and number libraries do not know these languages
&lt;/h2&gt;

&lt;p&gt;Not one indigenous tag we ship has CLDR date or number data, and only Nahuatl has plural rules. &lt;code&gt;Intl.DateTimeFormat("tzh")&lt;/code&gt; falls back silently, usually to the runtime default, which is not what you want. We route every date and number through a &lt;code&gt;formattingLocale()&lt;/code&gt; helper that maps each locale to the nearest language that has data. For these audiences that means formatting like Mexican Spanish. It is a pragmatic answer, not a right one; native speakers should decide how a date reads.&lt;/p&gt;

&lt;p&gt;Plural rules are the quieter problem: for a language with no CLDR rules the engine falls back to a default that may be wrong, so we avoid plural-sensitive phrasing where we can, and our check script treats broken ICU syntax as a hard error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: honest status beats a complete-looking list
&lt;/h2&gt;

&lt;p&gt;This is the part we most want other teams to copy. The tempting move is to generate a catalogue for every language and ship it. A machine-drafted sentence in a language the model barely knows can be confidently wrong, and the reader cannot tell. So each locale has an explicit status:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;reviewed&lt;/strong&gt;: a human who reads the language checked the catalogue. Seven locales: Spanish, English, Portuguese, French, German, Italian, Russian.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;machine&lt;/strong&gt;: generated and not reviewed by a native speaker. It carries a "beta" badge in the switcher. Nineteen locales are here, including Chinese, Korean, Japanese, Dutch, Arabic, Yucatec Maya, Nahuatl, Tzeltal and Tzotzil.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pending&lt;/strong&gt;: the file exists, but its contents are a structural copy of the fallback language. For a Mexican indigenous language the fallback is Spanish, because those readers already navigate in Spanish, and a Spanish screen is more honest than invented text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thirty-four indigenous catalogues are in the pending state today (7 reviewed + 19 machine + 34 pending = 60). They are in the list because the directive was to cover the languages, and they are &lt;code&gt;pending&lt;/code&gt; because we would rather say so than pretend.&lt;/p&gt;

&lt;p&gt;The part that matters for engineering is that "pending" is &lt;em&gt;derived&lt;/em&gt;, not hand-flagged. A pending locale renders &lt;code&gt;&amp;lt;html lang="es-MX"&amp;gt;&lt;/code&gt;, is excluded from &lt;code&gt;hreflang&lt;/code&gt; and the sitemap, gets &lt;code&gt;noindex&lt;/code&gt;, and reports &lt;code&gt;og:locale&lt;/code&gt; as &lt;code&gt;es_MX&lt;/code&gt;, so the markup never claims a language the page does not contain. The trap: the moment someone translates a pending file, the whole &lt;code&gt;lang&lt;/code&gt;/&lt;code&gt;hreflang&lt;/code&gt;/sitemap cluster switches on automatically, and nobody re-decides the tag. That is why the &lt;code&gt;mix&lt;/code&gt; problem matters now, while it is cheap to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: make the build enforce the boring invariants
&lt;/h2&gt;

&lt;p&gt;You cannot review 60 catalogues by eye. Our check script treats key parity, placeholder parity (&lt;code&gt;{count}&lt;/code&gt;, &lt;code&gt;{club}&lt;/code&gt;) and valid ICU syntax as hard errors in every locale. For pending locales it downgrades only two rule families to warnings: "identical to another locale" and CJK script purity. That lets us hold Spanish text in a Tzeltal file without failing the build, while still failing a catalogue that drops a key or a placeholder.&lt;/p&gt;

&lt;p&gt;One pitfall worth stealing: surfaces like our OG cards and the WhatsApp share layer once kept their own hand-maintained list of pending locales, and went stale on every promotion. We now derive every such list from the single status file and pin it with a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we have not done
&lt;/h2&gt;

&lt;p&gt;Being straight about this, because writing "60 locales" in a headline invites a different reading than the truth.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No native-speaker review yet&lt;/strong&gt; for any indigenous locale. The status is machine, and it says beta on the screen. Finding reviewers is the actual work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dozens of locales are Spanish text in disguise.&lt;/strong&gt; They are a commitment and a structure, not a finished product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every language is covered.&lt;/strong&gt; We excluded languages when ISO 639-3 splits them into many coordinate varieties with no obvious standard, and later accepted that risk for some by choosing the largest variety, with a requirement that a native reviewer confirms it before promotion. That decision can be wrong, and we say so in the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We have no usage numbers for these locales,&lt;/strong&gt; and will not make any up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are a speaker of Nahuatl, Yucatec Maya, Tseltal or Tsotsil and want to correct our strings, we would like to hear from you at &lt;a href="mailto:hola@meetandplay.mx"&gt;hola@meetandplay.mx&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Audit tags against ISO 639-3 and IANA before building on them; keep each catalogue's status in one file and derive everything else from it; prefer a labeled fallback to confident gibberish; and test keys, placeholders and ICU in every locale. The app is &lt;a href="https://meetandplay.mx/canchas" rel="noopener noreferrer"&gt;MeetAndPlay&lt;/a&gt;: find a game, find players, book a court.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: INEGI, Censo de Población y Vivienda 2020 (press release 430/22); ISO 639-3 code set (SIL); IANA Language Subtag Registry; RFC 5646.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>nextjs</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
