Graphics SDK: beta: the ligr.gfx.v1 protocol, the manifest, data binding, bundle rules, push and publish # Graphics SDK > Beta. Write a graphic in HTML, CSS and JavaScript, talk ligr.gfx.v1 to the overlay, and publish it into a theme over REST. Beta The Graphics SDK, the `ligr-graphic` CLI and the theme and code graphics REST routes are in beta. You create the theme your graphics live in, up to five per organization. The protocol, the manifest and the REST shapes can change between minor versions. Report code graphics, SDK, and CLI bugs, concerns, or feature requests through [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues). For app, account, or page problems, use [LIGR support](https://help.ligr.live/en/). See [Issues and support](/graphics-sdk/issues-and-support/) for the scope of each channel. A code graphic is a graphic you write yourself in HTML, CSS and JavaScript. The LIGR overlay loads it in a transparent frame over the video, sends it live match data, and tells it when to show and when to hide. It runs beside the Rive graphics in the same theme, and operators drive it from the same control room. For native `.riv`, `.rev`, or RML project imports, use [Rive graphics](/rive-graphics/). Native Rive playback does not use this browser SDK or the `ligr.gfx.v1` protocol. LIGR supplies the match data. You build a static browser bundle, push it over REST, and publish it. The platform does not require a framework or the SDK. Plain HTML, Canvas, WebGL and browser libraries use the same protocol. ## Downloads and repository [Section titled “Downloads and repository”](#downloads-and-repository) The [public graphics repository](https://github.com/ligrsystems/graphics-packages) hosts SDK and CLI releases and scoped GitHub Issues. It is a distribution repository. The implementation source remains in a private repository. Download versioned package archives from [Releases](https://github.com/ligrsystems/graphics-packages/releases) without a GitHub or npm account. Use [Issues and support](/graphics-sdk/issues-and-support/) to choose where to report a problem or request a feature. ## How it fits together [Section titled “How it fits together”](#how-it-fits-together) 1. You write the graphic and keep a manifest next to it. See [The manifest](/graphics-sdk/manifest/). 2. Your graphic answers the overlay over `postMessage` with the [`ligr.gfx.v1` protocol](/graphics-sdk/protocol/). The overlay sends match data, control variable values, theme variables and external data. See [Data binding](/graphics-sdk/data/). 3. You create a theme, create the graphic in it, upload the files, and record the bundle. See [Push and publish](/graphics-sdk/push-and-publish/). 4. You publish a graphic version, then a theme version that holds it. Overlays render the active theme version. Operators show and hide a code graphic the same way as any other graphic. Automation and the [graphics commands](/control-room/graphics-commands/) endpoint drive it by its control variables. You write none of that. You only build the bundle. ## The runtime [Section titled “The runtime”](#the-runtime) The overlay is a GPU-accelerated Chromium browser. On a LIGR stream, LIGR runs that browser on the encoder and composites its output over the video. On a vision mixer, the browser source of the mixer renders the same page. Your graphic runs in an iframe inside that page. The iframe has the `width` and `height` of your manifest. The overlay scales the iframe to fit the output and keeps its aspect ratio. A 1920 x 1080 graphic fills a 16:9 output at any resolution. Everything the browser offers is available to your code: * WebGL 1 and 2, WebGPU, and Canvas 2D for GPU-driven rendering. * CSS animations and transforms, and the Web Animations API. * WebAssembly and Web Workers. * Any rendering library that runs in a browser: Three.js, PixiJS, Rive, Lottie, GSAP. The overlay renders at the frame rate of the stream. Keep every frame under 16 ms at 60 fps. A graphic that stalls the page stalls every other graphic on the overlay. A vision mixer browser source runs on the browser of that mixer. Check its Chromium version before you rely on WebGPU there. ## Pages [Section titled “Pages”](#pages) | Page | What it covers | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | [Quick start](/graphics-sdk/quick-start/) | From an empty folder to a graphic on an overlay | | [The `ligr.gfx.v1` protocol](/graphics-sdk/protocol/) | Every message the overlay sends and accepts | | [The manifest](/graphics-sdk/manifest/) | The runtime, the control variables and the validation rules | | [Data binding](/graphics-sdk/data/) | Match, control variable, theme, image and data source values inside a graphic | | [Expressions](/graphics-sdk/expressions/) | The expression language, the `$d`, `$v`, `$t`, `$u` and `$x` context, and the helpers | | [Bundle rules](/graphics-sdk/bundle/) | Files, paths, fonts, size limits and the file name rules | | [Push and publish](/graphics-sdk/push-and-publish/) | Create, upload, record, publish, and the theme version | | [Test and troubleshoot](/graphics-sdk/test/) | `ligr-graphic dev`, the local harness, the checklist before hand-over, and the symptoms table | | [Issues and support](/graphics-sdk/issues-and-support/) | Code graphics feedback, bug reports, feature requests, and general app support | ## Beta status [Section titled “Beta status”](#beta-status) Graphics creation is in beta. This is what beta means today: * You create the theme with `npx ligr-graphic theme create` or `POST /v2/themes`. Once activated, it appears on the Themes page of the dashboard and in the competition theme picker like any other theme. Your organization can own up to five themes. A plan-level cap and pricing are on the roadmap. * The dashboard cannot create, edit or activate an owned theme yet. Use the CLI or REST for theme versions, and the dashboard to assign it to a competition and to operate it. * The `ligr.gfx.v1` protocol, the manifest and the theme and code graphics REST routes can change between minor versions. The [changelog](/changelog/) lists every change. * Every response from a beta route carries `X-Ligr-Beta: true`, and the operation carries `x-beta: "true"` in the OpenAPI document. See [Beta endpoints](/get-started/versioning/#beta-endpoints). * The `@ligrsystems/graphics-sdk` and `@ligrsystems/graphics-cli` packages ship as `0.x` versions. * Test every graphic on the preview overlay of a test match before you use it on air. ## What you need [Section titled “What you need”](#what-you-need) * A theme your organization owns. Create it with `npx ligr-graphic theme create`. * A write API key. See [Authentication](/get-started/authentication/). * Node 22 or later and a browser. * Optionally, `@ligrsystems/graphics-sdk`: a browser implementation of `ligr.gfx.v1` with callbacks for data and visibility. * The `@ligrsystems/graphics-cli` package: the `ligr-graphic` command that scaffolds, runs, validates, pushes and publishes a graphic. ## Install [Section titled “Install”](#install) Scaffold a graphic with the CLI. The scaffold adds both packages with exact GitHub release URLs to its `package.json`. Public package downloads need no npm account or GitHub authentication. Keep your lockfile in Git. Terminal ```bash npx --package=https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-cli-0.3.0.tgz ligr-graphic init my-graphic cd my-graphic npm install npm run build npm run dev ``` The default scaffold uses plain HTML, CSS and JavaScript. Framework templates are optional examples, not platform requirements. To add the optional runtime to an existing project, install the package and call `createGraphic`. Terminal ```bash npm install https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-sdk-0.3.0.tgz npm install --save-dev https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-cli-0.3.0.tgz ``` ## Size limits [Section titled “Size limits”](#size-limits) | Limit | Value | | -------------------- | ------ | | One bundle | 10 MiB | | One file | 5 MiB | | One protocol message | 1 MB | A bundle over the limit is refused with `BUNDLE_TOO_LARGE`. The `details[]` array names the files. See [Errors](/get-started/errors/) for `CODE_GRAPHIC_INVALID`, `BUNDLE_TOO_LARGE` and `INVALID_ASSET_PATH`. The SDK function `createGraphic()` starts a code-graphic runtime listener. It does not create a stored Rive graphic through REST. # Bundle rules > The files a bundle holds, relative paths, fonts and images, the transparent body, the size limits and the file name rules. A bundle is the folder of static files the overlay loads. LIGR serves it as static assets from its own CDN. Every rule on this page exists because a broadcast link is not a home connection, and because the overlay page renders your bundle over live video. ## Rules [Section titled “Rules”](#rules) * Ship plain browser files: HTML, CSS, JavaScript, fonts and images. * Name the entry file `index.html`, or name your entry file in the manifest. * Use relative paths only. If you use a build tool, set it to emit relative asset paths. * Do not run a server. LIGR serves your files as static assets. * Do not fetch JavaScript from anywhere at runtime. Bundle every module you need. * Ship your fonts and images inside the bundle. Do not link to an external font or image host. * Set the `` background to transparent. The video shows through it. * Make no network call at runtime. Every asset ships inside the bundle. Your graphic runs in a sandboxed frame with an opaque origin. It has no access to the overlay page, its cookies or its storage. Because the origin is opaque, every module script, font and file your graphic loads is a cross-origin request. LIGR serves the bundle with `Access-Control-Allow-Origin: *`, so files inside the bundle load. Ship only code you wrote or audited. Lay out your graphic at the `width` and `height` of the manifest. The iframe has that exact size. The overlay scales the iframe to fit the output and keeps its aspect ratio. Your layout never changes with the output resolution. If the aspect ratio of the manifest matches the output, the graphic fills the output. If the aspect ratios differ, the scaled graphic sits at the top-left corner and leaves an empty band at the right or at the bottom. ## Size limits [Section titled “Size limits”](#size-limits) | Limit | Value | | ------------------------ | ------------------------------------------------------------------------------------- | | One bundle | 10 MiB | | One file | 5 MiB | | Files per bundle request | 2000 | | Files per upload request | 200. Send `uploadSessionId` on the next request to add more files to the same session | `PUT …/bundle` checks the declared `size` of every file before it writes anything. It rejects an oversized bundle with `400 BUNDLE_TOO_LARGE` and lists every file over the limit in `details[]`. A large bundle loads slowly and can miss the show cue. Compress images. Subset fonts. Tree-shake your JavaScript. ## File names [Section titled “File names”](#file-names) A file name is a path inside the bundle, for example `assets/app.js`. It must stay inside the bundle folder. A name is refused with `400 INVALID_ASSET_PATH` when it holds: * `..` or an empty segment, * a control character, * one of `?`, `#` or `%`. Rename the file. A build tool that hashes file names produces valid names. ## Content types [Section titled “Content types”](#content-types) Send the content type of each file twice: as `contentType` when you request an upload URL, and as `mime` when you record the bundle. Send the same value in both places, and send the file body with that `Content-Type` header. Common values: | File | Content type | | -------- | ------------------------ | | `.html` | `text/html` | | `.js` | `application/javascript` | | `.css` | `text/css` | | `.woff2` | `font/woff2` | | `.png` | `image/png` | | `.svg` | `image/svg+xml` | | `.json` | `application/json` | ## Build tools [Section titled “Build tools”](#build-tools) You do not need a build tool. A bundle is plain browser files, so an `index.html` that loads your own `.js` and `.css` is a complete graphic. The default template works this way. If you do use one, it must write relative asset paths. Most bundlers default to absolute paths, which break when LIGR serves your bundle from a CDN sub-path. The React template uses Vite. Vite emits absolute paths unless you set the base: vite.config.ts ```ts import { defineConfig } from 'vite' export default defineConfig({ base: './' }) ``` Run the build. It writes `dist/index.html` plus a `dist/assets/` folder holding your JavaScript, CSS, fonts and images. Ship the whole `dist/` folder. # Data binding > The surfaces LOAD_GRAPHIC and GRAPHIC_UPDATE carry, the shape of each, the fields per sport, and the JSON Schema you can validate against. `LOAD_GRAPHIC` carries every surface. `GRAPHIC_UPDATE` carries the identity fields plus only the surfaces whose JSON changed. Merge an update over the state you hold. ## Identity fields [Section titled “Identity fields”](#identity-fields) | Field | Value | | ----------- | ------------------------------------------------------------------------------------------------------------- | | `graphicId` | The stable id of your graphic. Send it back in `HIDDEN` | | `show` | `true` while the graphic is on air. Informational. See [Show and hide](/graphics-sdk/protocol/#show-and-hide) | | `sport` | The sport key of the match, for example `football` | ## Surfaces [Section titled “Surfaces”](#surfaces) | Surface | Content | Keyed by | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | `sportData` | Live match data for the sport | Sport-specific field names | | `controlVariables` | The current value of each control variable in your manifest | Variable name | | `controlVariableData` | `{ value, data }` per control variable. `data` is the resolved fact, team or player object for an entity-typed variable | Variable name | | `themeVariables` | Theme variable values: colours, labels, sponsor names | Variable name | | `externalData` | The latest snapshot of each external data source of the theme | Data source alias | | `images` | Player, team and competition images | Entity arrays, see below | | `userExpressionValues` | The value of each user expression in your manifest, evaluated by the overlay. See [Expressions](/graphics-sdk/expressions/) | Expression name | | `assetOverrides` | Asset overrides such as ad images. Optional | Asset name | Read `controlVariableData..data` when you need the selected entity. Read `controlVariables.` when you need only the raw value. ## Images [Section titled “Images”](#images) ```json { "player": [{ "entityId": 501, "file": { "url": "https://…" } }], "team": [{ "entityId": 1, "file": { "url": "https://…" } }], "competition": [{ "entityId": 42, "file": { "url": "https://…" } }] } ``` `file` can be `null`. Handle a missing image before you render one. ## `sportData` by sport [Section titled “sportData by sport”](#sportdata-by-sport) Pick your sport. The tabs stay on your choice across these docs. * Football These fields come from the LIGR football data model. | Field | Meaning | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `sportData['1']` | The home team. A team object | | `sportData['2']` | The away team. A team object | | `sportData.clock` | The match clock, for example `"43:30"` | | `sportData.lastClock` | The clock frozen at the end of the last live period | | `sportData.clockRunning` | `true` while the clock counts | | `sportData.periodShortName` | The current period, for example `"First Half"` | | Team `.score` | Goals scored | | Team `.abbreviation` | The short team code, for example `"MUN"` | | Team `.logoUrl` | The team logo URL | | Team `.kit.primaryColor` | The kit primary colour, a hex string. If the team has no kit, the team primary background colour. `""` if neither is set | | Team `.kit.secondaryColor` | The kit secondary colour. If the team has no kit, the team primary text colour. `""` if neither is set | | Team `.redCards` | The red card count | | Team `.squad` | The player objects | `sportData['1']` is always the home team and `sportData['2']` is always the away team. `sportData.fixtures` lists the matches of the same competition on the same calendar day. The list includes the current match. It leaves out cancelled matches and matches without team competitors. LIGR sorts the list by date, then start time, then home team name. In a Rive expression, read the list as `$d.fixtures`. | Fixture field | Meaning | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `id` | The match id | | `date` | The match date, for example `"2026-09-29"` | | `homeTeamName`, `awayTeamName` | The team names | | `homeTeam`, `awayTeam` | The team branding: `logoUrl`, background and text colours, and `kit`. `null` if the match has no team on that side | | `homeGoals`, `awayGoals` | The goals of each team. `null` before kick-off | | `startTime` | The kick-off time in 24-hour format, for example `"15:00"` | | `isLive` | `true` while the match is in progress | | `round` | The round key of the match | | `currentPeriod` | The current period object. `null` if the match has no current period | For a field not listed here, read the schema. * Tennis **Coming soon.** The Tennis field reference is not written yet. Until then, `GET /v2/schemas/sports/tennis` returns the full `sportData` shape, and `GET /v2/scenarios/tennis` returns sample data. * Basketball **Coming soon.** The Basketball field reference is not written yet. Until then, `GET /v2/schemas/sports/basketball` returns the full `sportData` shape, and `GET /v2/scenarios/basketball` returns sample data. * Australian Rules **Coming soon.** The Australian Rules field reference is not written yet. Until then, `GET /v2/schemas/sports/ausRules` returns the full `sportData` shape, and `GET /v2/scenarios/ausRules` returns sample data. * Rugby League **Coming soon.** The Rugby League field reference is not written yet. Until then, `GET /v2/schemas/sports/rugbyLeague` returns the full `sportData` shape, and `GET /v2/scenarios/rugbyLeague` returns sample data. * Rugby Union **Coming soon.** The Rugby Union field reference is not written yet. Until then, `GET /v2/schemas/sports/rugbyUnion` returns the full `sportData` shape, and `GET /v2/scenarios/rugbyUnion` returns sample data. * Cricket **Coming soon.** The Cricket field reference is not written yet. Until then, `GET /v2/schemas/sports/cricket` returns the full `sportData` shape, and `GET /v2/scenarios/cricket` returns sample data. * Netball **Coming soon.** The Netball field reference is not written yet. Until then, `GET /v2/schemas/sports/netball` returns the full `sportData` shape, and `GET /v2/scenarios/netball` returns sample data. * More sports **Coming soon.** The field reference for these sports is not written yet. The sport key is in brackets. * Baseball (`baseball`) * Field hockey (`fieldHockey`) * Futsal (`futsal`) * American Football (`gridiron`) * Handball (`handBall`) * Ice hockey (`iceHockey`) * Lacrosse (`lacrosse`) * Rugby Sevens (`rugbySevens`) * Touch football (`touchFootball`) * Volleyball (`volleyball`) * Water polo (`waterPolo`) `GET /v2/schemas/sports/{sport}` returns the full `sportData` shape of each sport. ## Schemas [Section titled “Schemas”](#schemas) The overlay sends no schemas at runtime. Get each shape during development. | Surface | Where the schema is | | ------------------------------------------------ | ------------------------------------------------------------------- | | `sportData` | `GET /v2/schemas/sports/{sport}` | | Entities: `team`, `player`, `playerPair`, `fact` | `GET /v2/schemas/control-variables` | | `controlVariables` | The `controlVariables` of your manifest | | `themeVariables` | The theme variables of the theme | | `externalData` | The data schemas of the theme: `ligr-graphic rive data-schema list` | Validate your test payloads against these schemas during development. A wrong field name shows up before you ship. `GET /v2/scenarios/{sport}` returns sample `sportData` for a sport. See [Schemas & scenarios](/rest/operations/tags/schemas--scenarios/). ## External data [Section titled “External data”](#external-data) A theme can declare external data sources: a standings table, a sponsor line, a fact file. Your graphic reads them from `externalData` by alias. Anyone with a write key fills them over REST. See [External data sources](/control-room/data-sources/). A large data source is the usual cause of a message over the 1 MB limit. Keep the snapshot to what the graphic renders. # Expressions > The expression language, the evaluation context, the $d, $v, $t, $u and $x references, the helper functions, and the rules for missing data and errors. An expression is a line of JavaScript the overlay evaluates for you against the live match data. Rive graphics use expressions for every data binding. A code graphic uses them in the `userExpressions` array of its [manifest](/graphics-sdk/manifest/#userexpressions), and receives the results in `userExpressionValues`. The overlay evaluates every expression again on every data change. A user expression ```js $d.1.score > $d.2.score ? $d.1.name : $d.2.name ``` ## The language [Section titled “The language”](#the-language) An expression is JavaScript. The overlay evaluates it in a sandbox with the references and helpers on this page, and nothing else. * **One expression or many statements.** A single expression returns its value. A script of several statements returns the value of the last statement, with no `return`. `var`, `if`, `for`, `while` and function expressions all work. * **No browser globals.** `window`, `document`, `fetch`, `Date`, `RegExp`, `Promise`, `Symbol`, `Intl`, `Error` and timers are not in scope. Each one resolves to `undefined`. A call such as `new Date()` throws, and the binding falls back to its default. There is no clock in an expression. Send a time value through the match data or a theme variable instead. * **Regular expression literals work.** The `RegExp` constructor is absent, but `/live/i.test(x)` and `x.match(/\d+/)` both work, because a literal is syntax and not a global. * **The `NaN` and `Infinity` globals are absent.** Use `Number.NaN` and `Number.POSITIVE_INFINITY`, or test with `isNaN(x)`. * **No logical assignment.** `||=`, `&&=` and `??=` do not work on match data. The left side is read before the missing-data rules apply, so the assignment never happens. Write `x = x || 'fallback'` instead. * **No side effects.** An expression cannot change the match data, the variables or the graphic. * **Numeric keys with dot notation.** `$d.1.score` is rewritten to `$d['1'].score` before evaluation. Bracket notation works too. ## The context [Section titled “The context”](#the-context) | Reference | Holds | Keyed by | | --------- | ------------------------------------------------------------------------------------------------------- | ----------------- | | `$d` | The live match data for the sport. The same shape as `sportData` in [Data binding](/graphics-sdk/data/) | Sport field names | | `$v` | The control variables of the graphic. Each one is `{ value, data }` | Variable name | | `$t` | The theme variables: colours, labels, sponsor names | Variable name | | `$u` | The other user expressions of the graphic, by name, already evaluated | Expression name | | `$x` | The latest snapshot of each external data source of the theme | Data source alias | ### `$d`: match data [Section titled “$d: match data”](#d-match-data) `$d` is the sport data of the match. `$d.1` is the home team and `$d.2` is the away team in every team sport. Read the schema for the field list: `GET /v2/schemas/sports/{sport}` over REST. ```js $d.1.abbreviation // "MUN" $d.clock // "43:30" $d.1.startingLineup[0].lastName // The first starter of the home team $d.1.scorers?.[0]?.player.lastName ?? '' // Optional chaining works ``` ### `$v`: control variables [Section titled “$v: control variables”](#v-control-variables) A control variable is `{ value, data }`. `value` is what the operator set. `data` is the resolved entity for an entity-typed variable: the team, the player, the fact or the statistic. ```js $v.Title.value // "LINEUP" !$v.hide.value // true while the graphic is on air $v.Team.value // 1, the team id $v.Team.data.name // "Manchester United" $v.StatOne.data.names.plural // "Shots" $v.StatOne.data[1] // The home team value of the statistic Number($v.Rows.value) >= 3 // An enum value is a string ``` ### `$t`: theme variables [Section titled “$t: theme variables”](#t-theme-variables) A theme variable resolves to its value. A per-graphic override wins over the theme value, and the theme value wins over the default. ```js $t.primaryColor // "#0000ff" $d.isLive ? $t.liveLabel : $t.idleLabel ``` ### `$u`: user expressions [Section titled “$u: user expressions”](#u-user-expressions) A user expression can read another user expression by name. The overlay evaluates them in dependency order. An expression in a cycle, or one that throws, resolves to `null`. ```js $u.leader + ' leads by ' + $u.margin ``` ### `$x`: external data [Section titled “$x: external data”](#x-external-data) An external data source is a JSON or CSV snapshot the theme declares and a write key fills over REST. Read it by alias. See [External data sources](/control-room/data-sources/). ```js $x.results.rows[0].Home ?? '' $x.results.rows.length ``` ### List context [Section titled “List context”](#list-context) Inside a list binding of a Rive graphic, two more names are in scope. | Name | Value | | -------- | ---------------------------------------- | | `index` | The position of the current item, from 0 | | `length` | The number of items in the list | ## Helpers [Section titled “Helpers”](#helpers) | Helper | Returns | | ---------------------------- | ------------------------------------------------------------------------- | | `startsWith(str, prefix)` | `true` when `str` starts with `prefix`. `false` for a missing string | | `endsWith(str, suffix)` | `true` when `str` ends with `suffix`. `false` for a missing string | | `contains(strOrArray, item)` | `true` when the string or array holds `item`. `false` for a missing value | | `find(array, fn)` | The first item for which `fn` returns `true` | | `findIndex(array, fn)` | The index of that item, or `-1` | | `filter(array, fn)` | The items for which `fn` returns `true` | | `map(array, fn)` | A new array of `fn(item)` | | `reduce(array, fn, initial)` | The folded value | These JavaScript built-ins are in scope: `Math`, `Number`, `String`, `Boolean`, `Array`, `Object`, `JSON`, `parseInt`, `parseFloat`, `isNaN` and `isFinite`. String and array methods work on any value, so `$d.1.name.toUpperCase()` and `$d.1.squad.map(p => p.lastName)` both work. ## Missing data [Section titled “Missing data”](#missing-data) A missing value adapts to how you use it: blank as text, `0` in arithmetic. The same path works in both places, with no guard. ```js $d.1.aggregateScore // Blank in a text binding $d.1.aggregateScore + $d.1.score // Adds as 0 `agg ${$d.1.aggregateScore}` // "agg " ``` Missing means null, or a field this match does not carry. `0`, `false` and `''` are real values. They are never treated as missing. The members of the field’s type work as well, so you do not have to guard before you call a method. A chain never throws, however deep. ```js $d.1.coachName.toUpperCase() // '' $d.1.coachName.length // 0 $d.1.squad.map(p => p.lastName) // [] $d.1.squad.length // 0 $d.1.a.b.c // '' — no error ``` ### A path the schema does not know [Section titled “A path the schema does not know”](#a-path-the-schema-does-not-know) A path outside the schema has no type to adapt to. It reads as an object you can keep reading into, and a method call on it throws, so the binding falls back to its default. ```js $d.1.coachName.toUpperCase() // '' — the schema knows coachName is a string $d.1.notInTheSchema.toUpperCase() // Throws — check the field name against the schema ``` Read the schema before you write a path: `GET /v2/schemas/sports/{sport}`. ### Checking for a missing value [Section titled “Checking for a missing value”](#checking-for-a-missing-value) | Check | Matches | | ----------- | --------------------------------------------------- | | `x == null` | null and absent. `0`, `false` and `''` do not match | | `!x` | null and absent, and also `0`, `false` and `''` | Use `== null` when a zero is a real value you must keep on screen. Both `||` and `??` fall back on a missing value. A dash only when there is no aggregate score ```js $d.1.aggregateScore == null ? '-' : $d.1.aggregateScore ``` A zero is a real score ```js $d.1.score == null // false — the team has 0, which is a value !$d.1.score // true — 0 is falsy $d.1.coachName || 'TBC' ``` ## What the graphic receives [Section titled “What the graphic receives”](#what-the-graphic-receives) The value your expression returns is not always the value the graphic sees. ### Rive graphics [Section titled “Rive graphics”](#rive-graphics) Every binding coerces the value to the type of its view model property. This is why a missing number still shows `0` on a number property, even though the expression returned `''`. | Property type | Coercion | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `string` | `String(value)` | | `number` | `Number(value)`, and `0` when that is not a number | | `boolean` | Truthy, except `''`, `'0'`, `'false'` and `0`, which are all false | | `color` | A `#rgb`, `#rrggbb` or `#rrggbbaa` string, or a finite number read as ARGB (`0xFFFF0000` is opaque red). Any other value, such as `''` or `'red'`, is ignored and the graphic keeps its current color | | `image` | A URL string | An expression that throws never reaches the binding. The binding falls back to the default for its type. ### Code graphics [Section titled “Code graphics”](#code-graphics) A code graphic has no bindings, so nothing coerces the value. Each result arrives in `userExpressionValues` exactly as the expression returned it, including `''` for a missing number. Handle the type in your own code. In a code graphic ```js const agg = Number(values.aggregateScore) || 0 ``` An expression that throws, or one in a dependency cycle, arrives as `null`. ## Errors [Section titled “Errors”](#errors) The overlay logs every expression error. Test each expression against a rehearsal scenario before you publish: `GET /v2/scenarios/{sport}` returns sample `sportData` for the sport. Run it against a scenario with missing data as well as a full one. Most expression bugs only appear when a field is absent. ## Examples [Section titled “Examples”](#examples) Score line ```js $d.1.abbreviation + ' ' + $d.1.score + ' - ' + $d.2.score + ' ' + $d.2.abbreviation ``` Show a row only when it has data ```js $d.1.scorers?.[index] ? true : false ``` Goal minute with penalty and own goal markers ```js var event = $d.1.scorers?.[0]?.scoreEvents?.[0] var marker = event?._fact === 'OWN_GOAL' ? ' (OG)' : event?.isPenaltyGoal ? ' (P)' : '' event ? event.minute + "'" + marker : '' ``` Red card count for both teams ```js var cards = $d.1.redCards + $d.2.redCards cards === 0 ? '' : cards === 1 ? '1 red card' : cards + ' red cards' ``` Aggregate score, hidden when there is no first leg ```js $d.1.aggregateScore == null ? '' : '(' + $d.1.aggregateScore + ')' ``` Squad count that survives an absent squad ```js ($d.1.squad || []).length ``` # Issues and support > Where to report code graphics, SDK, and CLI issues, request features, or get help with the LIGR app. ## What the public repository contains [Section titled “What the public repository contains”](#what-the-public-repository-contains) The [graphics-packages repository](https://github.com/ligrsystems/graphics-packages) provides public SDK and CLI downloads and a scoped issue tracker. The implementation source remains in a private repository. [Releases](https://github.com/ligrsystems/graphics-packages/releases) contain versioned SDK and CLI archives, release notes, and checksums. You do not need a GitHub or npm account to download them. A GitHub account is required to open an issue. LIGR API authentication is separate. ## Choose the right channel [Section titled “Choose the right channel”](#choose-the-right-channel) | Topic | Where to go | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Code graphics authoring, runtime, protocol, or theme publishing bugs | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | SDK or CLI installation, commands, validation, or package bugs | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | Code graphics, SDK, or CLI questions, concerns, and feature requests | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | Incorrect code graphics documentation or examples | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | Broken app or documentation pages, login, account access, billing, or general platform problems | [LIGR support](https://help.ligr.live/en/) or in-app support | GitHub Issues covers the code graphics platform and its developer tools. This includes plain HTML, CSS, JavaScript, and any browser rendering library. ## Open a useful issue [Section titled “Open a useful issue”](#open-a-useful-issue) 1. Search [existing issues](https://github.com/ligrsystems/graphics-packages/issues) for the same problem or request. 2. Open a [new issue](https://github.com/ligrsystems/graphics-packages/issues/new/choose) and select the matching form. 3. For a bug, include package versions, browser or Node version, reproduction steps, and expected and actual results. 4. For a feature request, explain the code graphics use case and the behavior you need. 5. For a documentation correction, link the relevant example or instruction and describe the error. Issues are public. Remove API keys, credentials, private URLs, and customer data from examples and logs before posting. Use LIGR support for reports that require private account information. ## Automated issue checks [Section titled “Automated issue checks”](#automated-issue-checks) The issue bot checks the required form fields and declared topic when you open, edit, or reopen an issue. It can close incomplete reports or reports explicitly marked as general app or account problems. Its reply lists each failed requirement and explains how to correct the report or contact support. These checks help developers spend time investigating and fixing actionable code graphics problems. They are not a penalty for reporting a problem. We value your reports and apologize for the problems you encounter. If the bot closes your code graphics issue, edit it to address the listed requirements. The bot rechecks the report and reopens it when the requirements are met. Uncertain scope stays open for review, and maintainers can override the automated checks. # The manifest > The stateMachine of a code graphic. The runtime block, the control variables, the user expressions, and every rule the API checks on a write. The manifest describes your graphic to LIGR: the entry file, the frame size, the hide deadline, and the control variables an operator can set. Over REST it is the `stateMachine` field of a code graphic. Keep it in a file named `graphic.json` next to your source, and send it as `stateMachine` when you [record the bundle](/graphics-sdk/push-and-publish/#record-the-bundle). graphic.json ```json { "schemaVersion": 1, "runtime": { "engine": "iframe-html", "entryFile": "index.html", "width": 1920, "height": 1080, "exitDurationMs": 800 }, "controlVariables": [ { "id": "showAggregate", "name": "showAggregate", "type": "boolean", "defaultValue": false }, { "id": "accent", "name": "accent", "type": "enum", "defaultValue": "home", "options": ["home", "away", "neutral"] } ], "userExpressions": [] } ``` A graphic you create without a `stateMachine` gets this default: `index.html`, 1920 by 1080, a 1500 ms hide deadline, and no control variables. ## `runtime` [Section titled “runtime”](#runtime) | Field | Value | Rule | | ---------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `engine` | `iframe-html` | Required. The only engine today | | `entryFile` | The HTML file the overlay loads, as a path inside the bundle | Required. It must name an uploaded file at update and at publish | | `width` | Frame width in pixels | A positive integer. 1920 for a full-frame graphic | | `height` | Frame height in pixels | A positive integer. 1080 for a full-frame graphic | | `exitDurationMs` | How long the overlay waits for `HIDDEN` after `GRAPHIC_HIDE` before it hides the frame | Optional. A positive integer. Default 1500 | ## `controlVariables` [Section titled “controlVariables”](#controlvariables) A control variable is a value an operator sets in the control room, an automation sets from a rule, or a REST client sends with a [graphics command](/control-room/graphics-commands/). Your graphic receives the current values in `controlVariables` and, for entity types, the resolved object in `controlVariableData`. See [Data binding](/graphics-sdk/data/). You choose the variables. There is no platform list of variable names. Declare any name you need, pick a type from the catalogue below, and the control room renders the picker for it. The only fixed parts are the type catalogue and the reserved `hide` name. | Field | Value | | -------------- | ------------------------------------------------- | | `id` | The identifier. Use the same string as `name` | | `name` | The name. Unique within the graphic | | `type` | One of the types below | | `defaultValue` | The value in force when nothing sets the variable | | `options` | For `enum` only. The allowed values. At least one | ### Types [Section titled “Types”](#types) | Type | Value the graphic receives | Picker in the control room | | --------------------------------- | ----------------------------------------------------- | ------------------------------------- | | `string` | A string | Text field | | `number` | A number | Number field | | `boolean` | `true` or `false` | Switch | | `enum` | One of `options` | Drop-down | | `team` | A team id. `controlVariableData` carries the team | Team picker | | `player` | A player id. `controlVariableData` carries the player | Player picker | | `match` | A match id | Match picker | | `fact` | A fact id. `controlVariableData` carries the fact | Fact picker | | `teamStat` | A team statistic | Statistic picker | | `stat` | A statistic | Statistic picker | | `set`, `round`, `court`, `period` | A set, round, court or period selector | Selector for tennis and period sports | ### `hide` is reserved [Section titled “hide is reserved”](#hide-is-reserved) LIGR adds a boolean control variable named `hide` to every code graphic and sets it when the overlay shows or hides your graphic. Never declare it. A manifest that declares `hide` with any type other than `boolean` is refused. Your graphic still hides on `GRAPHIC_HIDE`, not on the value of `hide`. See [Show and hide](/graphics-sdk/protocol/#show-and-hide). ## `userExpressions` [Section titled “userExpressions”](#userexpressions) A user expression is a value the overlay computes for you from the match data, with the same expression language the Rive graphics use. The result arrives in `userExpressionValues`, keyed by expression name. Most code graphics leave this array empty and compute in JavaScript instead. ```json { "id": "leader", "name": "leader", "expression": "$d.1.score > $d.2.score ? $d.1.name : $d.2.name" } ``` | Field | Value | | ------------- | ---------------------------------------------------------------------------------------------- | | `id` | The identifier. Use the same string as `name` | | `name` | The key in `userExpressionValues`. Unique within the graphic | | `expression` | The expression. See [Expressions](/graphics-sdk/expressions/) for the language and the context | | `description` | Optional. A note for the editor | ## Validation [Section titled “Validation”](#validation) The API checks the manifest on every write: create, record the bundle, and publish. A failed check returns `400` with `code: "CODE_GRAPHIC_INVALID"` and one line per broken rule in `details[]`. | Rule | Message in `details[]` | | --------------------------------------------------------- | ------------------------------------------------------------------- | | `runtime` is an object | `runtime must be an object` | | `runtime.engine` is `iframe-html` | `runtime.engine must be "iframe-html"` | | `runtime.entryFile` is a path | `runtime.entryFile must be a file path` | | `runtime.entryFile` names an uploaded file | `runtime.entryFile "index.html" is not an uploaded code-file asset` | | `width`, `height`, `exitDurationMs` are positive integers | `runtime.width must be a positive integer` | | `controlVariables` is an array | `controlVariables must be an array` | | Every variable has a name | `controlVariable is missing a name` | | Names are unique | `controlVariables has duplicate name "title"` | | Every type is known | `controlVariable "title" has unknown type "text"` | | An enum has options | `controlVariable "accent" enum needs at least one option` | | An enum default is one of its options | `controlVariable "accent" default "blue" is not one of its options` | | A declared `hide` is boolean | `controlVariables.hide is reserved by LIGR and must be boolean` | | `userExpressions` is an array | `userExpressions must be an array` | The entry file rule runs at publish and when you send a `stateMachine` with the bundle. Record the files and the manifest in the same bundle request, so the check sees both. # The ligr.gfx.v1 protocol > The postMessage envelope, the handshake, every message the overlay sends and accepts, the hide rules and the message size limit. Your graphic and the LIGR overlay page talk over `postMessage`. The overlay is the parent window. Your graphic runs in an iframe. Every message in both directions uses one envelope and the protocol name `ligr.gfx.v1`. ## The envelope [Section titled “The envelope”](#the-envelope) ```json { "protocol": "ligr.gfx.v1", "sessionId": "cg_…", "seq": 12, "type": "GRAPHIC_UPDATE", "payload": {} } ``` | Field | Value | | ----------- | -------------------------------------------------------------------------------- | | `protocol` | Always `ligr.gfx.v1` | | `sessionId` | The session the overlay opened for this load of your frame. Copy it from `HELLO` | | `seq` | A counter. Each side keeps its own and increases it by 1 per message | | `type` | The message type. See the two tables below | | `payload` | The body of the message. Its shape depends on `type` | Two rules apply to every message you send. * **Echo the `sessionId`.** Copy the exact string from the `HELLO` message into every message you send. The overlay drops a message with a different `sessionId`. * **Increase `seq`.** Start your counter at 1 and add 1 on every message. The overlay does not refuse a gap, but it logs a warning for one. The overlay refuses a message over 1 MB in either direction. It reports the oversize message as a `GRAPHIC_ERROR` in its own log. Keep `externalData` and your own payloads small. ## The handshake [Section titled “The handshake”](#the-handshake) The overlay sends `HELLO` when your frame fires its `load` event. It repeats `HELLO` every 250 ms until you answer `READY`, up to 20 times. A graphic that answers no `HELLO` within 5 seconds is logged as an error and never loads. Answer the first `HELLO` you receive. You can also answer every `HELLO`. The overlay ignores every `READY` after the first one of a session. | Step | Direction | Type | Payload | | ---- | ----------------- | -------------- | ------------------------------------------ | | 1 | Overlay → graphic | `HELLO` | `{ width, height }`, the frame size | | 2 | Graphic → overlay | `READY` | `{ version }`, the version of your graphic | | 3 | Overlay → graphic | `LOAD_GRAPHIC` | The full data payload | From then on, every change arrives as a `GRAPHIC_UPDATE`. ## Overlay to graphic [Section titled “Overlay to graphic”](#overlay-to-graphic) | Type | When | Payload | | ---------------- | -------------------------------------------------------------- | ----------------------------------------------------------------- | | `HELLO` | On frame load, repeated until `READY` | `{ width, height }` | | `LOAD_GRAPHIC` | After `READY` | Every surface. See [Data binding](/graphics-sdk/data/) | | `GRAPHIC_UPDATE` | When any surface changes | `graphicId`, `show`, `sport`, plus only the surfaces that changed | | `GRAPHIC_HIDE` | An operator, an automation or a REST command hides the graphic | `{ graphicId }` | | `PING` | Every 10 seconds | `{}` | A `GRAPHIC_UPDATE` carries only the surfaces whose JSON changed since the previous message. Merge it over the state you hold. A surface that is absent did not change. If the overlay fails to deliver a `LOAD_GRAPHIC`, it sends the full load again on the next data change. If it fails to deliver a `GRAPHIC_UPDATE`, it drops its diff baseline and sends a full `LOAD_GRAPHIC` on the next data change. Your graphic must render correctly from a `LOAD_GRAPHIC` at any time, not only the first one. ## Graphic to overlay [Section titled “Graphic to overlay”](#graphic-to-overlay) | Type | When | Payload | | --------------- | --------------------------------- | ------------------------------------------------------ | | `READY` | You answer `HELLO` | `{ version }` | | `HIDDEN` | Your out-animation ends | `{ graphicId }` | | `PONG` | You answer `PING` | `{}` | | `ACK` | Optional, after any command | `{ ackSeq }`, the `seq` of the message you acknowledge | | `GRAPHIC_ERROR` | Your graphic hits a runtime error | `{ message, stack?, graphicId? }` | | `METRICS` | Optional | `{ graphicId, renderTimeMs, frameCount? }` | Answer every `PING` with a `PONG`. The overlay logs a warning when no `PONG` arrives within 5 seconds. ## Show and hide [Section titled “Show and hide”](#show-and-hide) 1. The overlay sends `GRAPHIC_HIDE`. 2. Your graphic plays its out-animation and sends `HIDDEN`. 3. The overlay hides the frame when `HIDDEN` arrives, or after `exitDurationMs` from the manifest. The default is 1500 ms. Two rules govern show and hide. * **`GRAPHIC_HIDE` is the only hide trigger.** Hide your graphic only when you receive it. Do not hide on any other message. * **`show` inside `GRAPHIC_UPDATE` is informational.** There is no separate show message. Treat `show: true` in a `GRAPHIC_UPDATE` that arrives after a hide as the signal to show your graphic again. Always send `HIDDEN` when your out-animation ends. If you never send it, the overlay hides the frame at the deadline and your animation looks cut off. If your animation is longer than the default deadline, raise `exitDurationMs` in the [manifest](/graphics-sdk/manifest/). ## Origin [Section titled “Origin”](#origin) Post messages to `window.parent`. Capture `event.origin` from the first `HELLO` you receive and use it as the target origin for every later message. Before you know the origin, `'*'` is accepted. Your graphic runs in a sandboxed frame with an opaque origin. It cannot read the overlay page. The overlay accepts messages only from your frame. See [Bundle rules](/graphics-sdk/bundle/). ## A complete listener [Section titled “A complete listener”](#a-complete-listener) `createGraphic` from the `@ligrsystems/graphics-sdk` package implements this listener, with the origin check, the merge of every `GRAPHIC_UPDATE`, and `HIDDEN` after your out-animation. Use the listener below when you cannot add a dependency. protocol.js ```js const PROTOCOL = 'ligr.gfx.v1' let sessionId = null let parentOrigin = '*' let seq = 0 export function send(type, payload) { window.parent.postMessage({ protocol: PROTOCOL, sessionId, seq: ++seq, type, payload }, parentOrigin) } export function listen(handlers) { window.addEventListener('message', (event) => { const msg = event.data if (!msg || msg.protocol !== PROTOCOL) return if (msg.type === 'HELLO') { sessionId = msg.sessionId parentOrigin = event.origin || '*' send('READY', { version: '1.0.0' }) return } if (msg.sessionId !== sessionId) return if (msg.type === 'PING') { send('PONG', {}) return } handlers[msg.type]?.(msg.payload) }) } ``` `handlers` maps `LOAD_GRAPHIC`, `GRAPHIC_UPDATE` and `GRAPHIC_HIDE` to your own functions. The [Quick start](/graphics-sdk/quick-start/) has a full graphic that uses this shape. # Push and publish > Create a code graphic in a theme, push the bundle, publish a graphic version, and publish the theme version that holds it. With the CLI in three commands, or over REST with a write key. Beta Graphics creation is in beta. See [Beta status](/graphics-sdk/#beta-status). Every write on this page needs a write key. Set `LIGR_API_KEY` in your shell and replace `203` with your theme id. The CLI sends requests to production by default. For another environment, set `LIGR_API_URL` to its full REST base, including `/rest/v2`, for example `https:///rest/v2`. You can also pass `--base-url`. The `curl` tutorials in [Import through REST](/rive-graphics/import-through-rest/) and [Set up through REST](/control-room/setup/) read the same `LIGR_API_URL`. `npx ligr-graphic theme list` or `GET /v2/themes` prints the themes your organization owns. ## With the CLI [Section titled “With the CLI”](#with-the-cli) `ligr-graphic` runs the calls below for you. See [Quick start](/graphics-sdk/quick-start/) for the scaffold. 1. **Create the theme.** Once per theme. The command prints the theme id. In a folder that holds `graphic.json`, it writes the theme id into `ligr.json`. `--variables ` adds theme variables from a JSON array of `{ id, name, type, defaultValue }`. Terminal ```bash npx ligr-graphic theme create --name "My Theme" --sports football ``` `npx ligr-graphic theme delete --theme 203 --yes` removes a theme you own and every graphic in it. A theme a competition still uses is refused. Remove the theme instance from the competition first. 2. **Create the graphic.** Once per graphic. The command writes `ligr.json` with the theme id and the graphic id. Terminal ```bash npx ligr-graphic create --theme 203 --name "Scorebug" --sports football ``` 3. **Push the bundle.** The command builds, validates, uploads only the files whose hash changed, and records the bundle with `graphic.json` as the manifest. Terminal ```bash npx ligr-graphic push ``` `npx ligr-graphic validate` runs the same checks without a push. `--skip-build` pushes the folder as it is. 4. **Publish the graphic, then the theme.** The command publishes a graphic version. With `--theme-version` it also publishes a theme version pinned to it. Terminal ```bash npx ligr-graphic publish --theme-version ``` Each publish creates a new graphic version, even when nothing changed since the last one. `npx ligr-graphic status` prints the working version, the published versions, the assets and the lock. 5. **Inspect and activate the published theme version.** Replace `12` with the version printed above. Terminal ```bash npx ligr-graphic theme inspect --theme 203 --version 12 npx ligr-graphic theme activate --theme 203 --version 12 ``` These commands select an existing snapshot. They do not publish another version or change its graphic selections. 6. **Publish one theme version after several graphics.** Publish each graphic without `--theme-version`. Then publish one theme version that pins the latest published version of every graphic. Terminal ```bash npx ligr-graphic theme publish --theme 203 --dry-run npx ligr-graphic theme publish --theme 203 ``` `--dry-run` prints the version number and the graphic versions, and writes nothing. The command prints the new version, each pinned graphic version, and whether the version is active. Add `--activate` to set it active. A graphic with no published version is not in the theme version. Caution Publish without `--activate`, then activate the reviewed version when the operator is ready. Reload the preview overlay to verify it. Existing broadcast sources keep their loaded version until reloaded. ## Without the CLI [Section titled “Without the CLI”](#without-the-cli) Send the key in `Authorization: Bearer `. The endpoints are under [Code graphics](/rest/operations/tags/code-graphics/) and [Themes](/rest/operations/tags/themes/) in the REST reference. Create the theme and graphic once, then upload and publish each revision. 1. **Create the theme.** Once per theme. Keep the `id` from the answer. `GET /v2/themes` lists the themes you own, and `DELETE /v2/themes/{themeId}` removes one that no competition uses. Terminal ```bash curl -X POST 'https://api.ligr.live/rest/v2/themes' \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "name": "My Theme", "sports": ["football"], "variables": [{ "id": "accent", "name": "accent", "type": "string", "defaultValue": "#ff0000" }] }' ``` 201 Created ```json { "id": 203, "name": "My Theme", "activeVersion": null, "versions": [], "graphics": [] } ``` The sixth theme returns `409 THEME_LIMIT_REACHED`. 2. **Create the graphic.** Once per graphic. Keep the `graphicId` from the answer. Terminal ```bash curl -X POST 'https://api.ligr.live/rest/v2/themes/203/code-graphics' \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "name": "Scorebug", "sports": ["football"] }' ``` 201 Created ```json { "graphicId": "e5515527-238e-44cb-9567-b65902218d47", "id": 1761, "name": "Scorebug", "type": "code", "version": 0, "sports": ["football"], "publishedVersions": [], "lock": null } ``` The answer holds two identifiers. Every later call in this guide takes `graphicId`, the UUID, in the path. `id` is the internal row number; it never goes in a path. A path with `id` in it answers `404`. `sports` defaults to the sports of the theme. Leave out `stateMachine` to get the default manifest. You send the real one with the bundle. 3. **Request an upload URL for each file.** One call opens one upload session. Terminal ```bash curl -X POST "https://api.ligr.live/rest/v2/themes/203/code-graphics/$GRAPHIC_ID/uploads" \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "files": [ { "name": "index.html", "contentType": "text/html" }, { "name": "assets/app.js", "contentType": "application/javascript" } ] }' ``` 200 OK ```json { "uploadSessionId": "823d14c6-ef4e-4d72-932e-20d8ed422e3c", "uploads": [ { "name": "index.html", "url": "https://…?X-Amz-Signature=…", "method": "PUT", "expiresInSeconds": 600 }, { "name": "assets/app.js", "url": "https://…?X-Amz-Signature=…", "method": "PUT", "expiresInSeconds": 600 } ] } ``` Keep the `uploadSessionId`. Each URL is valid for 10 minutes. One request signs at most 200 files. For more, send the same `uploadSessionId` in the next request. The answer keeps that id, and every URL writes into the same session. The CLI does this for you. 4. **Send each file to its URL.** Use the content type you declared. Terminal ```bash curl -X PUT "$UPLOAD_URL" \ -H 'Content-Type: text/html' \ --data-binary @dist/index.html ``` 5. **Record the bundle.** Name every file the graphic keeps, with the manifest. Terminal ```bash curl -X PUT "https://api.ligr.live/rest/v2/themes/203/code-graphics/$GRAPHIC_ID/bundle" \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "uploadSessionId": "823d14c6-ef4e-4d72-932e-20d8ed422e3c", "files": [ { "name": "index.html", "mime": "text/html", "size": 1240, "hash": "sha256:6f1c…" }, { "name": "assets/app.js", "mime": "application/javascript", "size": 8300, "hash": "sha256:9b02…" } ], "stateMachine": { "schemaVersion": 1, "runtime": { "engine": "iframe-html", "entryFile": "index.html", "width": 1920, "height": 1080, "exitDurationMs": 800 }, "controlVariables": [], "userExpressions": [] } }' ``` The answer is the working copy of the graphic, with its `assets` and its `publishedVersions`. 6. **Publish the graphic version.** Terminal ```bash curl -X POST "https://api.ligr.live/rest/v2/themes/203/code-graphics/$GRAPHIC_ID/versions" \ -H "Authorization: Bearer $LIGR_API_KEY" ``` 201 Created ```json { "version": 3 } ``` 7. **Publish a theme version that holds it.** Terminal ```bash curl -X POST 'https://api.ligr.live/rest/v2/themes/203/versions' \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "activate": false }' ``` 201 Created ```json { "version": 12, "activeVersion": 11 } ``` Inspect `GET /v2/themes/203/versions/12`, then select that exact snapshot: Terminal ```bash curl -X PUT 'https://api.ligr.live/rest/v2/themes/203/active-version' \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "version": 12 }' ``` Reload the preview overlay to load the selected version. See [Publish a theme version](/guides/publish-a-theme-version/) for pinning and rollback. Caution Activation selects the shared theme version for every competition using that theme. The customer dashboard has no activation action. Use the CLI or REST, then reload broadcast sources when the operator is ready. ## Upload sessions [Section titled “Upload sessions”](#upload-sessions) The first `POST …/uploads` call opens an upload session. Further batches can extend that session by sending its `uploadSessionId`. Every URL writes into that session, never directly into the graphic. A completed session cannot be reused. `PUT …/bundle` moves the files of the one session you name into the graphic. It applies one rule per file in `files`: | The file is | Result | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | In the named session | The request copies it into the working copy | | Not in the session, but the graphic already holds it | The request keeps it. Skip an unchanged file: compare its `hash` to the one in `GET …/code-graphics/{graphicId}` | | In neither place | The request fails with `CODE_GRAPHIC_INVALID` and names the file in `details[]` | A file you leave out of `files` is deleted. Leave out `uploadSessionId` only when every named file is already in the graphic. The bundle request clears the session it names when it succeeds. It leaves every other session alone. A publish never touches an upload session, so an upload URL you hold stays valid across a publish. ## Record the bundle [Section titled “Record the bundle”](#record-the-bundle) `files` is the complete file set: `name`, `mime`, `size` in bytes and `hash` for every file the graphic keeps. The API checks every `size` against the limits before it writes anything. The `hash` is yours: the API stores it and returns it, so a later push can compare and skip an unchanged file. `sha256:` is the usual form. `stateMachine` is optional. Leave it out to keep the stored manifest. When you send it, the API validates it against the file set of this request, so `runtime.entryFile` must be in `files`. See [The manifest](/graphics-sdk/manifest/). ## Versions [Section titled “Versions”](#versions) A publish turns the working copy into an immutable version and opens the next working copy. The `version` in the answer is the number a theme version pins. `GET …/code-graphics/{graphicId}` lists them in `publishedVersions`. A theme version is a frozen list of graphic versions. `POST /v2/themes/{themeId}/versions` pins every graphic you list at the version you give, and every graphic you do not list at its latest published version. A graphic with no published version is left out. Overlays load the active theme version when their page loads. Use `GET …/versions/{version}` to inspect a frozen selection and `PUT …/active-version` to activate or roll back to it. ## The lock [Section titled “The lock”](#the-lock) The dashboard editor takes a lock on a graphic while a person edits it. The lock stays for up to 60 seconds after the editor closes. | Request | Lock behaviour | | ----------------- | -------------------------------------------------------------------------------------------------------------------- | | `POST …/uploads` | Refuses to sign while another editor holds the lock. Does not take the lock, so a slow upload never blocks an editor | | `PUT …/bundle` | Holds the lock for the request | | `POST …/versions` | Holds the lock for the request | A request that meets a lock answers `409` with `code: "LOCKED"` and `holder.userName`. Wait, then retry. Do not delete the graphic and create it again. The API takes the lock as `API key `, and the same key can retake its own stale lock. ## Errors [Section titled “Errors”](#errors) | Status | Code | Cause | | ------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------- | | 400 | `CODE_GRAPHIC_INVALID` | The manifest breaks a rule, or a file in `files` is in neither the session nor the graphic. `details[]` names each problem | | 400 | `BUNDLE_TOO_LARGE` | A file is over 5 MiB, or the bundle is over 10 MiB. `details[]` names the files | | 400 | `INVALID_ASSET_PATH` | A file name holds `..`, an empty segment, a control character, or one of `?`, `#`, `%` | | 400 | `GRAPHIC_TYPE_MISMATCH` | The graphic is a Rive graphic, not a code graphic | | 400 | — | The body fails schema validation. The answer has `details` per field | | 403 | `INSUFFICIENT_SCOPE` | A read key on a write route | | 404 | — | The theme or the graphic does not exist, or your organization does not own it | | 409 | `LOCKED` | Another editor holds the lock | | 409 | `GRAPHIC_ASSET_MISSING` | A published asset or thumbnail is absent from storage. Upload the missing file, then publish again | | 409 | `GRAPHIC_ASSET_INVALID` | An asset cannot be read, or its size or SHA-256 differs. Replace the file, then publish again | See [Errors](/get-started/errors/) for the full map. ## Request count [Section titled “Request count”](#request-count) An upload batch is one request: `POST …/uploads` signs every file in one call. A full push of a graphic is four requests. See [Rate limits](/get-started/rate-limits/). # Quick start > From an empty folder to a scorebug on an overlay. Scaffold a graphic, run it in the local harness, then create, push and publish it with the CLI. Beta Graphics creation is in beta. See [Beta status](/graphics-sdk/#beta-status). Use Node 22 or later. Any package manager works; the examples use npm. You need a write API key, a browser and a terminal. Set `LIGR_API_KEY` in your shell. This example uses plain HTML, CSS and JavaScript. Any browser rendering library can use the same protocol and publishing tools. 1. **Scaffold the graphic.** Terminal ```bash npx --package=https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-cli-0.3.0.tgz ligr-graphic init my-graphic cd my-graphic npm install ``` Both packages use exact public GitHub release URLs. Package downloads need no npm account or GitHub authentication. Keep the generated lockfile in Git. The folder holds a scorebug, the manifest `graphic.json`, and two files for coding agents: `AGENTS.md` and `.claude/skills/ligr-graphic/SKILL.md`. Delete them if you do not use an agent. The default template uses one HTML file and a script that copies its files into `dist/`. Edit `index.html`. Its `createGraphic` callbacks receive match data and handle visibility. The hide callback resolves after the animation; the SDK then sends `HIDDEN`. The SDK is optional: any implementation of [the protocol](/graphics-sdk/protocol/) can run on LIGR. 2. **Run it in the local harness.** Terminal ```bash npm run build npm run dev ``` Open . The local viewer needs no dashboard login. It sends the same message order as the overlay. Open **Data**, choose a scenario, and press **Next** or **Play**. Edit declared variables under **Controls**, inspect **Files**, then press **Hide**. The log must show `READY` and `HIDDEN` from your graphic, and a `PONG` after the first `PING` at 10 seconds. See [Test and troubleshoot](/graphics-sdk/test/). Keep the harness running. Open a second terminal in the same project folder for the remaining commands. 3. **Create a theme.** Once per theme. Skip this step if your organization already has one: `npx ligr-graphic theme list` prints the themes you own. Terminal ```bash npx ligr-graphic theme create --name "My Theme" --sports football ``` The command prints the theme id and writes it into `ligr.json`, because the folder holds `graphic.json`. Your organization can own up to five themes. 4. **Create the graphic in the theme.** Terminal ```bash npx ligr-graphic create --name "Scorebug" --sports football ``` The command writes the graphic id into `ligr.json`. Pass `--theme ` to use a theme that is not in `ligr.json`. 5. **Push the bundle.** Terminal ```bash npx ligr-graphic push ``` The command builds, checks the manifest and the files with the API rules, uploads the changed files, and records the bundle. Run `npx ligr-graphic validate` to check without a push. 6. **Publish the graphic, then the theme.** Terminal ```bash npx ligr-graphic publish --theme-version ``` The command prints the published theme version. Inspect and activate that exact version when the broadcast allows it. Terminal — replace 1 with the published theme version ```bash npx ligr-graphic theme inspect --version 1 npx ligr-graphic theme activate --version 1 ``` Activation does not publish another version. Existing overlay pages need a reload to load the selected version. See [Push and publish](/graphics-sdk/push-and-publish/). 7. **Prepare the control room through REST.** Follow [Set up through REST](/control-room/setup/) with the theme and graphic IDs from the publishing steps. Create or reuse the competition, teams, venue, and match through REST. Create the theme profile, room, section, and graphic preset. Create an overlay with that profile and room, `autoMode: false`, and `adType: "noBrands"`. The API returns `controlRoomUrl`. Open that URL when setup is complete. Your browser needs a dashboard session with access to the same organization. 8. **Operate the prepared graphic.** Select **GFX In** on the preset. Verify the graphic on the preview overlay. After changing its variables, select **Update GFX** to apply the values while it is shown. Select **GFX Out** to hide it. REST commands also require manual mode; they do not enable it. See [Presets](/control-room/presets/) for preset commands, or address the graphic directly over REST: Set `OVERLAY_ID` to your match overlay’s numeric id. Terminal ```bash curl -X POST "https://api.ligr.live/rest/v2/overlays/$OVERLAY_ID/graphics/commands" \ -H "Authorization: Bearer $LIGR_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "command": "show", "name": "Scorebug" }' ``` ## What to do next [Section titled “What to do next”](#what-to-do-next) * Add control variables so an operator can change what the graphic shows. See [The manifest](/graphics-sdk/manifest/). * Read the other surfaces: theme variables, images and external data. See [Data binding](/graphics-sdk/data/). * Read the rules the bundle must follow. See [Bundle rules](/graphics-sdk/bundle/). * Write your own listener without the package. See [The protocol](/graphics-sdk/protocol/). * Push without the CLI, one REST call at a time. See [Push and publish](/graphics-sdk/push-and-publish/#without-the-cli). # Test and troubleshoot > npx ligr-graphic dev, a local harness that drives your graphic with the same message order as the overlay, the checklist before you hand over, and the symptoms table. Test your graphic before you push it. `npx ligr-graphic dev` runs a local harness that needs no API key. The viewer frames your graphic, sends the same message order as the LIGR overlay, and logs every message. It needs no dashboard login. Preview controls change local test state; they do not publish or update a saved graphic. ## The local harness [Section titled “The local harness”](#the-local-harness) Terminal ```bash npm run dev ``` `npm run dev` runs `npx ligr-graphic dev` in a scaffolded graphic. Open . The harness sends `HELLO` on frame load, waits for `READY`, then sends `LOAD_GRAPHIC`, and a `PING` every 10 seconds after that. That is the order and the cadence the overlay uses. The panel next to the frame holds: | Control | What it sends | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | **Data** → scenario picker | A `GRAPHIC_UPDATE` with the first step of the scenario | | **Previous**, **Next** | A `GRAPHIC_UPDATE` with the sport data of that step. Step `n` is steps `0..n` merged | | **Hide** | `GRAPHIC_HIDE`, then a `GRAPHIC_UPDATE` with `show: false`. The log reports when `HIDDEN` arrives against `exitDurationMs` | | **Show** | A `GRAPHIC_UPDATE` with `show: true` | | **Play**, **Pause** | Advance scenario steps once per second, or stop playback | | **Controls** | Edit declared control variables and send their current values | | **Data** | Inspect the current sport data and scenario step | | **Files** | Inspect the available local file names and sizes | | **Reset** | Restore the first step, default controls, and visible state, then reload the frame | | The log | Every message in both directions, with a warning for a `seq` gap and for a missing `PONG` | With `LIGR_API_KEY` set, the harness loads the scenarios of the sport you pass with `--sport` and the real JSON Schema of every surface from LIGR. Without a key it uses one bundled football scenario. The scenarios are the ones the LIGR graphics builder uses. See [Schemas & scenarios](/rest/operations/tags/schemas--scenarios/). With a `start` script in `package.json`, the harness runs it on the next free port and frames that server. Any development server can use this path; it must serve runnable browser files on the supplied port. Without a `start` script, the harness serves `dist/` and reloads the frame when a file under `dist/` changes. Terminal ```bash npx ligr-graphic dev --port 8080 --sport football ``` ### Without the CLI [Section titled “Without the CLI”](#without-the-cli) Save this file as `harness.html` next to your `dist/` folder. Serve the folder from any static file server, then open the harness in a browser. It sends the same first messages as `npx ligr-graphic dev`, with one sample payload and no scenarios. Terminal ```bash python3 -m http.server 8080 open http://localhost:8080/harness.html ``` harness.html ```html
``` Empty schema objects are fine for a local test. The overlay sends full JSON Schema. For real sample data, fetch `GET /v2/scenarios/{sport}` and paste one scenario into `samplePayload`. ## Dashboard code viewer [Section titled “Dashboard code viewer”](#dashboard-code-viewer) Open a code graphic from your theme to inspect it in the dashboard viewer. The dashboard viewer requires a session with access to the owning organization. It provides **Controls**, **Data**, and **Files**, plus scenario playback and **Show**, **Hide**, and **Reset**. A working version uses the current saved draft. A numbered version loads that published snapshot and its pinned files. Preview controls only change the current preview. They do not edit the published version. Use **Open working version** when you need the draft after inspecting a published version. ## Preview on an overlay [Section titled “Preview on an overlay”](#preview-on-an-overlay) After you [push and publish](/graphics-sdk/push-and-publish/), open a control room of a competition that uses the theme. Your graphic appears in the graphic list with its control variables. Show it on the preview overlay of a test match before you use it on air. ## Inspect the payload on an overlay [Section titled “Inspect the payload on an overlay”](#inspect-the-payload-on-an-overlay) The overlay frames your graphic in a sandbox, so the browser cannot inspect the frame from the page. Add `?debugCodeGraphics=1` to an overlay URL to see each data message that your graphic receives. Use one of these overlay URLs from the control room of a test match: | URL | Where to get it | | ---------------------------------------- | ------------------------------------------- | | `…/monitoring-{key}?debugCodeGraphics=1` | **Copy monitoring link** | | `…/preview-{key}?debugCodeGraphics=1` | **Preview Link** in the control room header | 1. Open the overlay URL with `?debugCodeGraphics=1` in a desktop browser. 2. Open the browser console and show messages at the **Verbose** level. 3. Find the `[CodeGraphicIframe] payload` line with `type: 'LOAD_GRAPHIC'`. It holds the full payload of the load. 4. Paste this listener to print each later message as an object: Browser console ```js addEventListener('ligr:code-graphic-payload', e => console.log(e.detail)) ``` Each event has `graphicId`, `type`, `seq`, and `payload`. `type` is `LOAD_GRAPHIC` or `GRAPHIC_UPDATE`. A `GRAPHIC_UPDATE` payload holds only the changed surfaces. `seq` is the same value that your graphic receives. The overlay reads the flag when it loads a graphic. If you add the flag to an open overlay, reload the page. The flag changes no graphic output, and it sends no network request. ## Checklist [Section titled “Checklist”](#checklist) Check every item before you push a version for a broadcast. * The handshake, `HELLO` through `LOAD_GRAPHIC`, completes within 2 seconds. * The graphic renders correctly from `LOAD_GRAPHIC` alone, with no earlier `GRAPHIC_UPDATE`. * The graphic updates correctly on a partial `GRAPHIC_UPDATE`, for example a score change with no clock change. * The graphic sends `HIDDEN` within `exitDurationMs` of `GRAPHIC_HIDE`. * The browser console shows no error across a full show and hide cycle. * The graphic makes no network call at runtime. * The built `dist/` folder is under 10 MiB, and no file is over 5 MiB. * The `` background stays transparent in every state. * The animation holds 60 frames per second during show and hide. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) | Symptom | Likely cause | Fix | | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | The frame stays blank | The entry file never sends `READY`, or a script error stopped it | Open the browser console. Check the harness log for a missing `READY` | | The graphic never receives data | You echoed the wrong `sessionId`, or you never sent `READY` | Copy `sessionId` from `HELLO` exactly. Send `READY` before you expect `LOAD_GRAPHIC` | | The graphic never hides | You never send `HIDDEN`, or `exitDurationMs` is shorter than your animation | Send `HIDDEN` when the out-animation ends. Raise `exitDurationMs` in the manifest | | A message fails with a 1 MB error | A payload, usually `externalData`, is too large | Trim the data source. Load large assets from the bundle instead of the payload | | Fonts do not load | A font path is absolute, or the font is missing from the bundle | Use relative paths. Ship the font file inside `dist/` | | The wrong team shows on the left | The home and away fields are swapped | `sportData['1']` is the home team. `sportData['2']` is the away team | | `CODE_GRAPHIC_INVALID` on push or publish | The manifest breaks a rule, or a file was not uploaded in the session you named | Read `details[]`. Each line is one problem. See [The manifest](/graphics-sdk/manifest/#validation) | | `409 LOCKED` on push or publish | A person has the graphic open in the dashboard editor, or a stale lock has not expired | Read `holder.userName`. Wait up to 60 seconds, then retry | | `404` on a theme you expect to reach | The API key belongs to another organization | Use a key of the organization that owns the theme | | The frame is blank on the overlay but fine in the harness | The bundle did not upload, or the entry file is missing | `GET …/code-graphics/{graphicId}` lists `assets`. Check the entry file is there | ## Report a problem [Section titled “Report a problem”](#report-a-problem) Use [Issues and support](/graphics-sdk/issues-and-support/) for code graphics, SDK, and CLI reports. General app problems, including broken pages, login, and billing, go to LIGR support.