com.looping.sdk
v0.1.1
Published
Put a Unity Web build on Looping: C# rooms (relay or authoritative, with the reconnect grace window, room kinds, matchmaking, private rooms by invite code, parties, teams and the host's lobby), the player's identity, saves, rounds and scores, and purchase
Maintainers
Readme
com.looping.sdk
The Looping package for Unity 6: a C# Looping API for a Unity Web build on Looping, over @looping/sdk 0.6.0 and its Unity adapter. It gives the game multiplayer rooms (relay or authoritative, with the 15-second reconnect grace window), room kinds, matchmaking with backfill, private rooms with invite codes, parties, teams and the host's lobby, persistent worlds, the public room list and player reports, the player's identity, saves (with formats and migrations, conflict merging, history, restore, clear and guest claims), rounds, scores, the leaderboard and the game's boards, verified records, purchases through the platform's own checkout (the catalog, consumables and paid files), and the platform's check hook. It also ships the Looping WebGL template and two menus, Looping ▸ Apply Web Settings and Looping ▸ Build Web.
Verified on Unity 6000.6.3f1 (2026-09-26). The package and its two test projects compile with no errors or warnings, build for Web in Brotli, Gzip and Disabled, pass Looping's publish inspection, and play in Chromium, Firefox and WebKit. That covers relay, a private lobby, matchmaking with teams, the out-of-credits refusal, and an authoritative arena with 2 and 4 players.
The contract is the SDK guide, SDK.md, and the Unity guide is UNITY.md: an agent reads both with the Looping MCP's engine_docs { engine: "sdk", doc: "SDK.md" } and engine_docs { engine: "sdk", doc: "UNITY.md" }, and follows the looping-unity skill.
Install
Add Looping's scoped registry and the package to the project's Packages/manifest.json, keeping what is already there:
{
"scopedRegistries": [
{ "name": "Looping", "url": "https://registry.npmjs.org", "scopes": ["com.looping"] }
],
"dependencies": { "com.looping.sdk": "0.1.1" }
}Unity's Package Manager then installs it, and lists updates, from the registry. Every asset ships with its .meta, so the package imports cleanly. It needs the package only for Looping's features in C# (rooms, the store, saves, leaderboards, records, worlds); a plain Unity Web build uploads to Looping without it.
The Looping WebGL template
Unity looks for WebGL templates only in Assets/WebGLTemplates, and a package can't put files there. So the package keeps its template in WebGLTemplates~/Looping (Unity doesn't import folders ending in ~), and Looping ▸ Apply Web Settings copies index.html and thumbnail.png into Assets/WebGLTemplates/Looping and selects it (PlayerSettings.WebGL.template = "PROJECT:Looping").
- Commit that folder with the game.
- Running the menu again after a package update replaces a copy you haven't changed. If you edited
index.html, it asks before replacing it. In batch mode it keeps your copy and warns, so after a package update deleteAssets/WebGLTemplates/Looping/index.htmlbefore a batch-mode build. - Any other file you put in
Assets/WebGLTemplates/Loopingships at the build root next toindex.html(Unity leavesthumbnail.pngout).rules.jsfor authoritative rooms goes there. - Unity preprocesses the
.html,.css,.jsand.jsonfiles in a template folder. Keep{{{,}}}and lines starting#if,#elseor#endifout of yourrules.js.
The template does this:
- Loads
/_looping/engines/[email protected]/src/index.jsand…/src/unity.jsfrom the game's own origin (every Looping game host serves them), pinned to the full version. - Starts Unity with Unity's loader, with a loading bar and plain error screens (a missing loader, a threaded build, no WebGL 2, a page opened from a file). Any other failure is one plain line, with the technical text under a collapsed Details.
- Calls
attach(unityInstance)so events reach C#. - Answers Unity's webcam device list with none: Looping game hosts deny the camera and microphone, and Safari would log permission errors at startup.
- At Unity's first frame, hides the loading screen and calls
looping.ready().
If the SDK can't load, the game still starts: a banner says that multiplayer, saves and scores are off, and C# calls fail with bridge_unavailable.
Apply Web Settings
The settings Looping supports:
| Setting | Value |
|---|---|
| WebGL template | Looping |
| Compression | Brotli |
| Decompression fallback | off |
| Name files as hashes | on |
| Native C/C++ multithreading | off (required: Looping can't host threaded builds) |
| Graphics APIs | WebGL 2 only |
| Data caching | on |
| Exceptions | explicitly thrown only |
| Code optimization | Disk Size with LTO (needs Web Build Support; set by reflection on UnityEditor.WebGL.UserBuildSettings) |
| Run in background | on |
Looping ▸ Build Web applies them and builds the scene list to Build/Web as a release build. It then checks the output against Looping's limits (2 GB in all, 1,000 files) and prints the game_publish { dir } to run. From a terminal:
Unity -batchmode -quit -buildTarget WebGL -projectPath . -executeMethod Looping.Editor.LoopingBuild.BuildWeb -logFile -The API
Payloads are JSON text: write them with JsonUtility.ToJson and read them with JsonUtility.FromJson. Field names of the data objects follow the JavaScript SDK (player, name, signedIn).
using Looping;
Player.SetName("Zed"); // a guest's name
Rooms.OnJoined += room => Debug.Log($"in {room.name} as {room.me}, host {room.host}, {room.mode}");
Rooms.OnMessage += (from, json) => { var move = JsonUtility.FromJson<Move>(json); };
Rooms.OnState += (key, json) => { /* json is null when the key was deleted */ };
Rooms.OnPlayerAway += id => { /* dropped; seat held for 15 s */ };
Rooms.OnPlayerBack += id => { };
Rooms.OnClose += (code, willReconnect) => { };
Rooms.OnReconnect += resumed => Rooms.Send(JsonUtility.ToJson(myPosition));
Rooms.OnError += (code, message) => Debug.LogWarning($"{code}: {message}");
Rooms.Join("lobby-1", max: 4, isPublic: true);
Rooms.Send(JsonUtility.ToJson(move)); // relay: to everyone else (or Send(json, to: id))
Rooms.Set("phase", "\"playing\""); // relay: shared state, last writer wins; null deletes
Rooms.Input(JsonUtility.ToJson(new Command { seq = ++seq, dir = 1 })); // authoritative: seq must grow
Rooms.OnView += (tick, json) => { }; // authoritative: our view after a tick
Rooms.OnResult += json => { }; // authoritative: the signed result
Rooms.Leave();
Rooms.Join("arena-1", kind: "ranked"); // one of the game's room kinds (rooms_configure { kinds })
Rooms.OnQueue += q => status = $"{q.waiting} of {q.size} waiting";
Rooms.Match(new MatchOptions { kind = "squads" }); // seated in a match room: Rooms.Current.match, room.team
Rooms.Create(kind: "skirmish"); // private: share Rooms.Current.invite.code (or .link)
var code = Rooms.Invited; if (code != null) Rooms.JoinInvite(code); // opened from an invite link
Rooms.OnPhase += (phase, locked) => { }; // "lobby", "playing" or "ended"
if (Rooms.IsHost) { Rooms.Lock(); Rooms.Start(); } // the host's calls; the room refuses anyone else ("host")
Party.OnChanged += party => { /* party.code, party.members, party.IsLeader */ };
Party.Create(); if (Party.Current.IsLeader) Party.Match(new MatchOptions { kind = "squads" });
var me = Player.Me; // { player, name, signedIn }, null before a token exists
Saves.Load((data, error) => { if (data != null && data.HasData) progress = JsonUtility.FromJson<Progress>(data.json); });
Saves.Save(JsonUtility.ToJson(progress), (data, error) => { if (error != null && error.IsStale) { /* merge data.json, save again */ } });
Saves.Load(2, (data, error) => { if (data != null && data.format < 2) { /* upgrade data.json */ } }); // formats; data.readOnly when a newer build wrote it
Saves.History((versions, error) => { }); Saves.Restore(versions[0].id); Saves.Clear();
Saves.OnClaimed += () => Saves.Load(OnLoaded); // the page moved a guest's save onto the account
Scores.StartRound((round, error) => Random.InitState((int)round.seed));
Scores.FinishRound(1234);
Store.Buy("golden-lantern", (purchase, error) => { });
Store.Owned((items, error) => { });
Platform.AutoReady = false; /* … */ Platform.Ready(); // when the game has its own loading screen
Rooms.List((rooms, error) => { /* rooms[i].name, .players, .max, .kind: a world picker */ });
Rooms.Report(playerId, "cheating", "speed hack in round 3");
Saves.MergeConflict = c => JsonUtility.ToJson(Merge(JsonUtility.FromJson<Progress>(c.mine), JsonUtility.FromJson<Progress>(c.theirs)));
Saves.Migrate = (json, from, to) => Upgrade(json, from, to); Saves.Load(3, OnLoaded); // arrives already in format 3
Store.Items((catalog, error) => { foreach (var item in catalog.items) AddButton(item.name, item.label); });
Store.PaidUrl("paid/map-pack/maps.bundle", (url, error) => StartCoroutine(LoadBundle(url)));
Scores.Leaderboard(10, (rows, error) => { });
Records.Counter("gold", (gold, error) => { });
Platform.OnCheck += check => StartCoroutine(RunCheck(check)); // in Awake; end with check.Done()
// SDK 0.5.0: backfill, the game's boards, and persistent worlds.
Rooms.Match(new MatchOptions { kind = "coop" }); // with the kind's backfill on, may seat us in a running match: Rooms.Current.backfilled
Rooms.OnPlayerJoined += p => { if (p.backfilled && Rooms.IsHost) Rooms.Send(JsonUtility.ToJson(snapshot), p.id); };
Boards.Post("laps", lapMs, "monaco", (post, error) => { /* post.stored */ });
Boards.Read("laps", new BoardReadOptions { split = "monaco", aroundMe = true }, (page, error) => { /* page.entries, page.me */ });
Scores.StartRound("laps", "monaco", (round, error) => { }); // the round's FinishRound score posts to that board
Rooms.Join("survival-1", kind: "survival"); // a world kind's room: Worlds.Current
Worlds.Load(new[] { "0,0" }, (chunks, error) => { /* chunks[0].json null: generate it from Worlds.Current.seed */ });
Worlds.OnChunk += c => Apply(c.key, c.json);
Worlds.Patch("0,0", "{\"3:4\":\"stone\"}");| Member | What it does |
|---|---|
| Rooms.Join(name, max = 8, isPublic = false, kind = null) | Joins or creates a room, leaving the one we were in. kind names one of the game's room kinds for a new room. OnJoined, or OnError with the platform's code (banned, 4402, 4413 and so on). |
| Rooms.Match(options), Rooms.CancelMatch() | Matchmaking for a kind (MatchOptions: kind, size, teams, minPlayers, waitMs, filterJson). OnQueue while it waits, then OnJoined in the match room. |
| Rooms.Create(kind, isPrivate = true, max), Rooms.JoinInvite(code), Rooms.Invited | Private rooms: Current.invite is { code, link }; a wrong code is OnError("4404"). |
| Rooms.SetTeam(id, team), Kick(id), Lock(), Unlock(), Start(), End() | The host's calls. False while disconnected; the room refuses anyone else with OnError("host"). |
| Party.Create(), Party.Join(code), Party.Match(options), Party.Cancel(), Party.Leave() | A party that queues together; its members and the leader's search arrive as Party.OnChanged and Party.OnQueue, the match as Rooms.OnJoined. |
| Rooms.Send(json, to = null) | Relay. False while disconnected, over 4 KiB, faster than the room allows, or in an authoritative room. Nothing is queued. |
| Rooms.Set(key, json) | Relay shared state. null deletes the key. |
| Rooms.Input(json) | Authoritative. A JSON object with seq, a whole number that grows with every input, at most 2 KiB. |
| Rooms.Leave() | Ends our seat at once. |
| Rooms.Current, Players, Me, IsHost, Connected, CloseReason | The room as its events left it: its kind, teams, team, phase, locked, invite, match and matchData too. |
| events | OnJoined, OnMessage(from, json), OnState(key, json), OnView(tick, json), OnMatch(json), OnResult(json), OnPlayerJoined, OnPlayerLeft(id, reason), OnPlayerAway, OnPlayerBack, OnHost, OnTeam(id, team), OnPhase(phase, locked), OnQueue(info), OnClose(code, willReconnect), OnReconnect(resumed), OnError(code, message) |
| CloseCode.OutOfCredits (4402), LoopingError.IsOutOfCredits | The game's rooms can't be paid for: a join refused 4402, or a match warned credits and then closed 4402. Saves and scores keep working. |
| Player.Me, Player.SetName, Player.Identify | Identity: a per-game pseudonym, never an account, an email or a wallet. |
| Saves.Load([format]), Saves.Save | One JSON document per player per game; a stale error hands back the other copy. With a format, data.format is what the save was written with and data.readOnly says a newer build wrote it. |
| Saves.History, Saves.Restore(id), Saves.Clear(), Saves.ClaimGuest(choice) | Earlier versions (kept a week), making one current, starting over, and moving this browser's guest save onto the account. |
| Saves.OnSaved(ver), Saves.OnError(code, message), Saves.OnClaimed | A kept save that landed later, every save failure, and the page's guest claim (without a handler the page reloads the game instead). |
| Scores.StartRound, Scores.FinishRound | A platform-minted round, and the score with the player's room token. |
| Store.Buy(sku, [pay]), Store.Owned, Store.Consume(sku, count) | The platform's checkout (optionally in one pay choice: GAME, GG, USDG or ETH); the game only learns the result. What the player owns, and spending a consumable. |
| Store.Items | The catalog to draw a shop from: each StoreItem's name, kind, label and prices in each pay choice. |
| Store.PaidUrl(path) | A paid file (paid/<sku>/<file> in the build) as a URL with a content ticket, for UnityWebRequest or an AssetBundle, loaded at once (the ticket lasts a few minutes). claim when the player doesn't own the sku. |
| Rooms.List([kind]) | The public rooms with players in them, newest first (RoomListing: name, players, max, createdAt, kind): a lobby or world picker. Needs no room. |
| Rooms.Report(target, reason, note) | Reports a player (cheating, harassment, impersonation, spam or other) to the creator; people decide. |
| Rooms.Refresh(), Rooms.Here(), Player.Identify(true, …) | After a purchase: a fresh identity (Player.Me.claims), then the room reconnects with it. Here keeps a quiet seat from the idle close when the player uses a gamepad. |
| Saves.MergeConflict, Saves.Migrate, Saves.Flush | Answers to the page while it waits: what to keep when another tab saved first (null: theirs), and an older save upgraded for Load(format, …). Answer at once (no coroutines). Flush sends a held save before quitting. |
| Scores.Leaderboard(limit), Records.Counter(kind) | The game's leaderboard (LeaderboardEntry), and one verified counter's value. Records.Summary, List and Get read the rest. |
| Platform.OnCheck, Platform.ReportError | The platform's build check (CheckInfo: join check.room, buy one of check.skus, then check.Done()), and a boot error reported before Ready. |
| Platform.AutoReady, Platform.Ready(), Platform.IsWebBuild, Platform.IsAvailable | The ready signal, and whether the bridge is there. |
| MatchOptions.backfill, RoomInfo.backfilled, MatchInfo.backfill, RoomPlayer.backfilled | Backfill (sdk 0.5.0): with the kind's backfill on, the queue may seat a player in a running match short a player (true by default; false asks for a fresh match). Catch a backfilled player up from the shared state, or the host's Send(json, id). |
| Boards.List, Boards.Read(board, options), Boards.Post(board, value, [split]), Scores.StartRound(board, split) | The game's boards (sdk 0.5.0, leaderboards_configure): BoardInfo, a BoardPage of BoardEntry rows with the player's own (me, null when none), a player-reported post (BoardPost), and a round whose score posts to a board. |
| Worlds.Current, Load(keys), Get, IsLoaded, Loaded, Patch(key, fieldsJson), Put(key, json), Unload(keys) | A world kind's room's persistent world (sdk 0.5.0, WorldInfo): chunks as JSON text (WorldChunk), null when nobody wrote one. |
| Worlds.OnChunk, OnWiped(epoch, seed), OnNotice(wipeAt), OnMembers, OnError(code, message) | A loaded chunk changed, a wipe, a scheduled wipe's notice, an owner world's members, and a world refusal no call waited for. |
| Worlds.List(kind), Worlds.Open(kind), Worlds.Visit(ownerId, kind), AddBuilder, RemoveBuilder, AddVisitor, RemoveVisitor | The game's worlds (WorldListing), a player's own world and another's (joined as the one room: Rooms.OnJoined), and the owner's members (WorldMembers). |
Events arrive on Unity's main thread from the page (through SendMessage to the LoopingBridge object the package creates before the first scene loads). Every subscriber runs even when another throws.
Calling early. A call made before the page's adapter is there fails with bridge_unavailable on the next frame, and Rooms.Send/Set/Input return false. That happens with another WebGL template, or when the SDK didn't load. Events that arrive before Unity is attached wait in the adapter, in order.
The Editor. The bridge runs only in compiled Web builds. In Play Mode and on other platforms every call logs "The Looping bridge runs in Web builds" once and fails with web_only. It never fails silently.
Keyboard. Unity keeps capturing every key in the game's frame (WebGLInput.captureAllKeyboardInput) except while an HTML input of the page itself has focus. The adapter switches it off and back on around that input.
JsonUtility. It reads null in a string field as "" (a test purchase's txHash is empty, not null), and an absent nested object as a default instance.
Newer than the verification. Looping.Records, Store.Consume and the calls after Platform.Ready() in the example above (with Store.Buy's pay choice, Player.Identify(true, …), Saves.Flush, Rooms.Refresh, Rooms.Here, backfill, Looping.Boards and Looping.Worlds) were written after the 2026-09-26 run. On 2026-10-08 they compiled with both test projects on Unity 6000.6.3f1, with no errors or warnings, and both built for Web in Brotli and passed the publish inspection. The store's and the leaderboards' calls among them (Store.Items, Store.Consume, Scores.Leaderboard, Scores.StartRound(board, split) and Looping.Boards) then played in Chromium and Firefox, against Looping's local test harness with the store in test mode; the rest have not been played in a browser yet. They need @looping/sdk 0.6.0's adapter on the page (with 0.4.0 each fails with bridge_unavailable). If one fails to compile on another Editor, report the file, the line and the Editor version.
Not in 0.1.1
- Round declarations (
startRound({ level, stage, params, bring, spend })) and replay tapes: they serve replay verification, which a Unity build doesn't have (its scores are player-reported). Spend a consumable withStore.Consume. looping.player()(the page's{ signedIn, name }):Player.Mehas both. The room token (rooms.token()) never reaches C#.- Webcam and microphone: every Looping game host denies both.
- Authority written in C#. An authoritative room runs the build's JavaScript
rules.js. In a relay room, the host can decide in C#. - Unity Netcode, Mirror, Photon and the like. They don't run over Looping rooms, and the game's security policy blocks their servers.
