# SERENTECH Overlay Integration Guide

This guide is the handoff contract for broadcast graphics, venue screens, livestream overlays, and other real-time display clients that need official MPFL match numbers.

## Base URLs

Use the same integration shape for local and cloud sources.

Local Edge is preferred on-site:

```text
http://serentech.local:8787
http://10.1.100.1:8787
http://192.168.8.1:8787
http://192.168.1.1:8787
http://10.0.0.1:8787
http://127.0.0.1:8787
```

`10.1.100.0/24` is the preferred MPFL venue Edge LAN; `10.1.100.1:8787` is the highest-priority static candidate after the mDNS name and any operator-supplied URL.

Cloud fallback is:

```text
https://api.mpfl.my
```

Cloud fallback reads the provisional Edge -> MPFL direct-push cache. The
current MPFL cache is per process and per `matchId`; it can hold multiple
match IDs in one process, but it is not restart-safe, shared across instances,
or proof of concurrent official venue readiness.

## Discovery Flow

1. If an operator supplied a local URL, probe that first.
2. Probe the last known working local URL.
3. Probe `http://serentech.local:8787/health`.
4. Probe the static candidates from `GET /api/serentech/matches.meta.localBaseUrlCandidates` (single-host probes only — typical venue router and loopback gateways).
5. If still unresolved and `meta.subnetSweepHints.enabled` is true, run the **real-netmask offline-LAN sweep**. Derive the client's IPv4 interfaces (RTCPeerConnection ICE in the browser, `os.networkInterfaces()` in Node, `NetworkInterface.list` in Dart), resolve the subnet from the actual interface mask, and probe `http://<host>:<port>/health` for hosts in that resolved subnet with `probeTimeoutMs` (default 2000ms), `concurrency` parallel requests (default 16), and `sweepBudgetMs` (default 20000ms). Stop on the first HTTP 200 and save it as the operator URL. Skip the sweep entirely if a static candidate from step 4 on that same subnet already answered — the edge is found.
6. If no local Edge responds within roughly 2 seconds (plus the optional sweep budget, default 20 seconds), use `https://api.mpfl.my`.

Each single-host local probe should use `GET /health` with a 700ms timeout. Subnet sweep probes should use `meta.subnetSweepHints.probeTimeoutMs` so slow venue WiFi does not produce false negatives. Local Edge wins whenever it is reachable because it is closest to the official clock and continues during venue internet loss.

> Sweeping foreign subnets is wasted work: packets either don't route past the client's default gateway or are black-holed. Use the subnet implied by the client's actual interface mask; when the platform does not expose a mask, use the RFC1918 fallback prefixes from `meta.subnetSweepHints.rfc1918HeuristicPrefix` (`/24` for `192.168.x`, `/20` for `10.x` and `172.16-31.x`). Known venue gateway IPs (e.g. `10.1.100.1`, `192.168.8.1`) belong in `localBaseUrlCandidates` as single-host probes, not in sweep ranges.

## Authentication

Cloud provider routes require a SERENTECH API key:

```sh
curl -H "Authorization: Bearer sk_serentech_example" https://api.mpfl.my/api/serentech/matches
curl -H "x-api-key: sk_serentech_example" https://api.mpfl.my/api/serentech/matches
```

Local Edge routes on the venue LAN do not use the MPFL cloud provider key in this version.

## Match Selection

Cloud match list:

```sh
curl -H "Authorization: Bearer sk_serentech_example" https://api.mpfl.my/api/serentech/matches
```

Local Edge match list:

```sh
curl http://serentech.local:8787/matches
```

Both lists expose the official `matchId`. Use that same value for state, stream, logs, support screenshots, and operator handoff notes.

## Live State

Prefer Server-Sent Events.

Cloud:

```sh
curl -N -H "Authorization: Bearer sk_serentech_example" https://api.mpfl.my/api/serentech/matches/mpfl-2026-r01-jdt-sel-001/stream
```

Cloud live state is provisional. Certified stats come from the separate MPFL
certification lane, and the current cloud push cache is not a durable
multi-instance live-clock source.

Local:

```sh
curl -N http://serentech.local:8787/stream/matches/mpfl-2026-r01-jdt-sel-001/state
```

If SSE disconnects, poll once per second while reconnecting:

```sh
curl -H "Authorization: Bearer sk_serentech_example" https://api.mpfl.my/api/serentech/matches/mpfl-2026-r01-jdt-sel-001/state
curl http://serentech.local:8787/matches/mpfl-2026-r01-jdt-sel-001/state
```

## Operator Event Writes

Latency-sensitive operator actions are local Edge writes only. Cards, fouls, clock changes, horn, shootout, match lock, and match certification should be posted to the venue LAN endpoint:

```sh
curl -X POST http://serentech.local:8787/matches/mpfl-2026-r01-jdt-sel-001/events \
  -H "Content-Type: application/json" \
  -d '{"id":"evt-001","matchId":"mpfl-2026-r01-jdt-sel-001","type":"goalHome","actorRole":"tableOfficial","clockRemainingMs":719000,"payload":{"teamSide":"home","teamCode":"JDT","playerNumber":7,"playerSlug":"mat-nawi","playerName":"Mat Nawi","playerPosition":"PV","isUnknownPlayer":false}}'
```

The MPFL cloud provider API is read-only for overlay consumers. Do not send latency-sensitive operator event writes to `https://api.mpfl.my` with a provider key. Cloud `POST /api/serentech/matches/:matchId/events` is reserved for Edge-to-cloud ingest after the local Edge has accepted the action.

```json
{
  "accepted": true
}
```

Every accepted local operator action must still create an immutable `MatchEvent` record. Edge stores the event in the local ledger, projects the latest state immediately, serves that state to local overlays, and may sync the event/state to cloud asynchronously when connectivity is available.

## Fallback Rules

- Never blank the overlay when a request fails.
- Keep the last known score, clock, period, fouls, cards, and team labels visible.
- Mark the feed stale after 3 seconds without an SSE event or successful poll.
- Continue reconnecting SSE with backoff while polling state every 1000ms.
- Prefer local Edge again as soon as it becomes healthy.

## Static Asset Caching

Cache static URLs returned by the API, including `crestUrl`, team logos, fonts, and overlay media, by full URL. If the URL includes a `v` query parameter, keep it as part of the cache key. Do not refetch static assets on every state poll or SSE event; reuse the cached copy until the URL changes, the HTTP cache expires, or an operator explicitly clears the cache.

Live state is allowed to update every second. Static assets should be loaded independently from the live polling loop so displays do not repeatedly request the same crests or media during a match.

## Minimal Overlay Algorithm

```js
const candidates = [
  operatorUrl,
  savedLocalUrl,
  'http://serentech.local:8787',
  'http://10.1.100.1:8787',
  'http://192.168.8.1:8787',
  'http://192.168.1.1:8787',
  'http://10.0.0.1:8787',
  'http://127.0.0.1:8787'
].filter(Boolean);

async function probe(url, timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(`${url}/health`, { signal: controller.signal });
    return response.ok ? url : null;
  } catch {
    return null;
  } finally {
    clearTimeout(timer);
  }
}

async function discoverLocalBase({ subnetSweepHints } = {}) {
  for (const baseUrl of candidates) {
    const hit = await probe(baseUrl, 700);
    if (hit) return hit;
  }
  if (subnetSweepHints?.enabled) {
    const hit = await sweepLocalSubnet(subnetSweepHints);
    if (hit) return hit;
  }
  return 'https://api.mpfl.my';
}
```

For the optional sweep, derive the client's own subnet from the real interface mask and probe that range only. If the client cannot determine its own subnet (e.g. browser without ICE access), skip the sweep and fall back to cloud:

```js
async function sweepLocalSubnet(hints) {
  const subnet = await getLocalSubnet(hints); // e.g. { prefix: "10.1.96", prefixLength: 20 }
  if (!subnet) return null;
  const deadline = Date.now() + (hints.sweepBudgetMs ?? 20000);
  const port = hints.port ?? 8787;
  const hosts = hostsForSubnet(subnet).map((host) => `http://${host}:${port}`);
  return raceConcurrent(
    hosts,
    hints.concurrency ?? 16,
    (url) => probe(url, hints.probeTimeoutMs ?? 2000),
    deadline
  );
}

// Browser: use RTCPeerConnection to enumerate ICE candidates and pick the
// first non-loopback, non-link-local IPv4 host. Use the interface mask when
// available; otherwise apply hints.rfc1918HeuristicPrefix.
//
// Node: iterate os.networkInterfaces(), skip loopback/internal/IPv6,
// and use the interface netmask.
async function getLocalSubnet(hints) {
  // ... platform-specific implementation
}

function hostsForSubnet(subnet) {
  // Expand the resolved IPv4 CIDR, excluding network and broadcast addresses.
  // ... platform-specific implementation
}

async function raceConcurrent(urls, concurrency, probeFn, deadline) {
  let index = 0;
  return new Promise((resolve) => {
    let active = 0;
    let resolved = false;
    const next = () => {
      if (resolved) return;
      if (Date.now() > deadline) return resolve(null);
      while (active < concurrency && index < urls.length) {
        const url = urls[index++];
        active += 1;
        probeFn(url).then((hit) => {
          if (resolved) return;
          if (hit) { resolved = true; resolve(hit); return; }
          active -= 1;
          if (index >= urls.length && active === 0) resolve(null);
          else next();
        });
      }
    };
    next();
  });
}
```

Save the first responding `<host>` as the operator URL so subsequent matchdays skip the sweep. Respect `meta.subnetSweepHints.enabled = false` and any operator-level override; the sweep is opt-in.

Note: `meta.subnetSweepHints.ranges`, `hostStart`, and `hostEnd` are legacy /24 artifacts and are no longer used to select sweep targets. Clients should sweep only the subnet resolved from their own interface. If a future deployment ships a venue Edge on a known-static IP that the client can reach via routing (rare), add that IP to `localBaseUrlCandidates` instead, so it's probed as a single host in step 4.

## Expected Match List Shape

```json
{
  "source": "fixtures",
  "meta": {
    "cloudBaseUrl": "https://api.mpfl.my",
    "localBaseUrlCandidates": [
      "http://serentech.local:8787",
      "http://10.1.100.1:8787",
      "http://192.168.8.1:8787",
      "http://192.168.1.1:8787",
      "http://10.0.0.1:8787",
      "http://127.0.0.1:8787"
    ],
    "subnetSweepHints": {
      "enabled": true,
      "scope": "realNetmask",
      "port": 8787,
      "healthPath": "/health",
      "probeTimeoutMs": 2000,
      "concurrency": 16,
      "sweepBudgetMs": 20000,
      "rfc1918HeuristicPrefix": {
        "192.168.0.0/16": 24,
        "10.0.0.0/8": 20,
        "172.16.0.0/12": 20
      }
    },
    "pollIntervalMs": 1000,
    "staleAfterMs": 3000
  },
  "data": [
    {
      "matchId": "mpfl-2026-r01-jdt-sel-001",
      "home": "JDT",
      "away": "SEL",
      "homeCode": "JDT",
      "awayCode": "SEL",
      "homeTeam": {
        "code": "JDT",
        "name": "Johor Darul Ta'zim",
        "crestUrl": "https://mpfl.my/assets/jdt-crest.svg?v=20260510-serentech",
        "primaryColour": "#E2D505",
        "color": "#E2D505",
        "colorDark": "#1A1464",
        "score": null,
        "fouls": 0,
        "coach": { "name": "Coaching Staff TBC", "photoUrl": "" },
        "players": [
          {
            "id": "jdt-mohamad-awalluddin-mat-nawi",
            "slug": "mohamad-awalluddin-mat-nawi",
            "name": "Mohamad Awalluddin Mat Nawi",
            "number": 5,
            "position": "PV",
            "starter": true,
            "photoUrl": ""
          }
        ]
      },
      "awayTeam": {
        "code": "SEL",
        "name": "Selangor FC",
        "crestUrl": "https://mpfl.my/assets/sel-crest.svg?v=20260510-serentech",
        "primaryColour": "#DD1E35",
        "color": "#DD1E35",
        "colorDark": "#8A0E1E",
        "score": null,
        "fouls": 0,
        "coach": { "name": "Coaching Staff TBC", "photoUrl": "" },
        "players": []
      },
      "graphics": {
        "home": { "code": "JDT", "players": [{ "id": "jdt-mohamad-awalluddin-mat-nawi", "starter": true }] },
        "away": { "code": "SEL", "players": [] }
      },
      "matchup": {
        "homeCode": "JDT",
        "awayCode": "SEL",
        "homeName": "Johor Darul Ta'zim",
        "awayName": "Selangor FC",
        "label": "JDT vs SEL",
        "displayLabel": "Johor Darul Ta'zim vs Selangor FC"
      },
      "venue": "Pasir Gudang Indoor Stadium",
      "status": "Preview",
      "clockRemainingMs": null,
      "score": { "home": null, "away": null, "hasScore": false },
      "scorers": [],
      "events": [{ "type": "yellow_card", "team": "JDT", "playerId": "jdt-mohamad-awalluddin-mat-nawi", "minute": 12 }],
      "standings": [{ "pos": 1, "club": "Johor Darul Ta'zim", "code": "JDT", "pts": 0 }],
      "stateUrl": "/api/serentech/matches/mpfl-2026-r01-jdt-sel-001/state",
      "streamUrl": "/api/serentech/matches/mpfl-2026-r01-jdt-sel-001/stream"
    }
  ]
}
```

## State Response Shape

Cloud state routes return an envelope with `source` and `data`. Local Edge state routes return the state object directly.

```json
{
  "source": "edge",
  "data": {
    "matchId": "mpfl-2026-r01-jdt-sel-001",
    "competition": "MPFL 2026",
    "venue": "Pasir Gudang Indoor Stadium",
    "period": "1st Half",
    "clockRemainingMs": 719000,
    "clockRunning": true,
    "home": {
      "code": "JDT",
      "name": "Johor Darul Ta'zim",
      "score": 2,
      "fouls": 3,
      "cards": "1Y"
    },
    "away": {
      "code": "SEL",
      "name": "Selangor FC",
      "score": 1,
      "fouls": 4,
      "cards": ""
    },
    "timeoutTeamCode": null,
    "lastSignal": null,
    "eventCount": 42,
    "updatedAt": "2026-05-10T13:00:00.000Z"
  }
}
```

## SSE Event Shape

SSE streams emit `state` events. The `data` line is the latest state object as JSON.

```text
event: state
data: {"matchId":"mpfl-2026-r01-jdt-sel-001","period":"1st Half","clockRemainingMs":719000,"clockRunning":true,"home":{"code":"JDT","name":"Johor Darul Ta'zim","score":2,"fouls":3,"cards":"1Y"},"away":{"code":"SEL","name":"Selangor FC","score":1,"fouls":4,"cards":""},"eventCount":42}
```

## Local Edge Match List Shape

Local Edge keeps the match list intentionally lighter, because it is built from the local match configuration and projection.

```json
{
  "source": "edge",
  "meta": {
    "healthPath": "/health",
    "localMatchesPath": "/matches",
    "localStatePathTemplate": "/matches/:matchId/state",
    "localStreamPathTemplate": "/stream/matches/:matchId/state",
    "pollIntervalMs": 1000,
    "staleAfterMs": 3000
  },
  "data": [
    {
      "matchId": "mpfl-2026-r01-jdt-sel-001",
      "venue": "Pasir Gudang Indoor Stadium",
      "home": "JDT",
      "homeName": "Johor Darul Ta'zim",
      "away": "SEL",
      "awayName": "Selangor FC",
      "status": "Live",
      "stateUrl": "/matches/mpfl-2026-r01-jdt-sel-001/state",
      "streamUrl": "/stream/matches/mpfl-2026-r01-jdt-sel-001/state"
    }
  ]
}
```

## Operational Notes

Before matchday, verify:

- SERENTECH Runtime or Edge responds to `GET /health`.
- The SERENTECH app has selected or configured the correct `matchId`.
- The overlay app can list matches from local Edge.
- The overlay app can use cloud fallback with the issued SERENTECH API key.
- The overlay team understands cloud fallback is provisional direct-push cache
  state, not restart-safe or shared multi-instance provider durability.
- The overlay app keeps the last known state visible when the local server is stopped during a rehearsal.
