Writing a client

Windows · macOS · Android · home

A complete updater, in the order the calls should be made. The shape below is what a host keeping a Roblox client up to date actually needs; adapt the install step to your target.

The loop

  1. Ask what should be installed.
  2. Compare against what is installed. Stop if equal.
  3. Download, following redirects.
  4. Verify the sha256.
  5. Install according to kind.
  6. Record the version you installed.

Compare version, not displayVersion in step 2. The dotted string is not unique, so two different desktop builds can carry the same one and an updater keyed on it will skip a real update.

bash

#!/usr/bin/env bash
set -euo pipefail

API="https://rbxoffsets.com"
PLATFORM="${1:-windows}"           # windows | macos | android
STATE="/var/lib/roblox-tracker/$PLATFORM-version"
mkdir -p "$(dirname "$STATE")"

# 1. what should be installed
meta="$(curl -fsS "$API/api/v1/$PLATFORM/files/latest")"
version="$(printf '%s' "$meta" | jq -r .version)"
kind="$(printf '%s' "$meta" | jq -r .kind)"
name="$(printf '%s' "$meta" | jq -r .fileName)"
want_sha="$(printf '%s' "$meta" | jq -r .sha256)"

# 2. already there?  (compare the identity, never displayVersion)
if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$version" ]; then
  echo "up to date: $version"; exit 0
fi

# 3. download - -L is required, the endpoint redirects to Cloudflare
out="/tmp/$name"
curl -fL --retry 3 --retry-delay 5 -o "$out" "$API/download/$PLATFORM/$version"

# 4. verify
if [ -n "$want_sha" ]; then
  got="$(sha256sum "$out" | cut -d' ' -f1)"
  [ "$got" = "$want_sha" ] || { echo "sha256 mismatch, refusing"; rm -f "$out"; exit 1; }
else
  echo "warning: this build is unpinned (no published sha256)"
fi

# 5. install
case "$kind" in
  apk)  adb install -r "$out" ;;
  xapk) tmp="$(mktemp -d)"; unzip -q "$out" -d "$tmp"
        adb install-multiple "$tmp"/*.apk ;;
  zip)  unzip -oq "$out" -d /opt/roblox ;;
esac

# 6. remember
printf '%s' "$version" > "$STATE"
rm -f "$out"
echo "installed $version"

python

import hashlib, requests

API = "https://rbxoffsets.com"
PLATFORM = "windows"

meta = requests.get(f"{API}/api/v1/{PLATFORM}/files/latest", timeout=15).json()

with requests.get(f"{API}/download/{PLATFORM}/{meta['version']}", stream=True,
                  allow_redirects=True, timeout=(15, 600)) as r:
    r.raise_for_status()
    digest = hashlib.sha256()
    with open(meta["fileName"], "wb") as f:
        for chunk in r.iter_content(1 << 20):
            f.write(chunk)
            digest.update(chunk)

if meta["sha256"] and digest.hexdigest() != meta["sha256"]:
    raise SystemExit("sha256 mismatch")

Offsets for the build you run, in eight languages

The other half of most clients: ask for the offsets of the exact build installed, verified only, and stop if there are none. Each example follows the redirect and refuses to substitute.

Python (standard library only)

import urllib.request, urllib.error

API = "https://rbxoffsets.com"
version = open("installed-version.txt").read().strip()
try:
    with urllib.request.urlopen(f"{API}/api/v1/windows/offsets/{version}/hpp?tier=verified") as r:
        open("offsets.hpp", "wb").write(r.read())       # urllib follows the 302
        print("tier:", r.headers.get("x-offsets-tier"))
except urllib.error.HTTPError as e:
    raise SystemExit(f"no verified offsets for {version}: {e.code}")

Node.js / TypeScript (18+)

const API = "https://rbxoffsets.com";
const version = (await (await fetch(`${API}/api/v1/windows/version.txt`)).text()).trim();
const res = await fetch(`${API}/api/v1/windows/offsets/${version}/hpp?tier=verified`);
if (!res.ok) throw new Error(`no verified offsets for ${version}: ${res.status}`);
await (await import("node:fs/promises")).writeFile("offsets.hpp", Buffer.from(await res.arrayBuffer()));

C# (.NET 6+)

using var http = new HttpClient();                      // follows redirects by default
const string Api = "https://rbxoffsets.com";
var version = (await http.GetStringAsync($"{Api}/api/v1/windows/version.txt")).Trim();
var res = await http.GetAsync($"{Api}/api/v1/windows/offsets/{version}/hpp?tier=verified");
if (!res.IsSuccessStatusCode) throw new Exception($"no verified offsets for {version}: {(int)res.StatusCode}");
await File.WriteAllBytesAsync("offsets.hpp", await res.Content.ReadAsByteArrayAsync());

C++ (Windows, WinHTTP)

// Link winhttp.lib. WinHTTP follows the 302 to storage automatically.
#include <windows.h>
#include <winhttp.h>
#include <string>

std::string Get(const std::wstring& host, const std::wstring& path, DWORD* status) {
  std::string body;
  HINTERNET s = WinHttpOpen(L"my-client/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, 0);
  HINTERNET c = WinHttpConnect(s, host.c_str(), INTERNET_DEFAULT_HTTPS_PORT, 0);
  HINTERNET r = WinHttpOpenRequest(c, L"GET", path.c_str(), nullptr, nullptr, nullptr, WINHTTP_FLAG_SECURE);
  if (WinHttpSendRequest(r, nullptr, 0, nullptr, 0, 0, 0) && WinHttpReceiveResponse(r, nullptr)) {
    DWORD size = sizeof(*status);
    WinHttpQueryHeaders(r, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, nullptr, status, &size, nullptr);
    for (DWORD n = 0; WinHttpQueryDataAvailable(r, &n) && n;) {
      std::string chunk(n, '\0'); DWORD read = 0;
      WinHttpReadData(r, chunk.data(), n, &read); body.append(chunk, 0, read);
    }
  }
  WinHttpCloseHandle(r); WinHttpCloseHandle(c); WinHttpCloseHandle(s);
  return body;
}

// DWORD status = 0;
// auto hpp = Get(L"your.host", L"/api/v1/windows/offsets/" + version + L"/hpp?tier=verified", &status);
// if (status != 200) { /* no verified offsets for this build: do not attach */ }

Rust (reqwest, blocking)

let api = "https://rbxoffsets.com";
let version = reqwest::blocking::get(format!("{api}/api/v1/windows/version.txt"))?.text()?.trim().to_string();
let res = reqwest::blocking::get(format!("{api}/api/v1/windows/offsets/{version}/hpp?tier=verified"))?;
if !res.status().is_success() {
    anyhow::bail!("no verified offsets for {version}: {}", res.status());
}
std::fs::write("offsets.hpp", res.bytes()?)?;

Go

api := "https://rbxoffsets.com"
v, _ := http.Get(api + "/api/v1/windows/version.txt")
raw, _ := io.ReadAll(v.Body); v.Body.Close()
version := strings.TrimSpace(string(raw))

res, err := http.Get(api + "/api/v1/windows/offsets/" + version + "/hpp?tier=verified") // follows the 302
if err != nil || res.StatusCode != 200 {
    log.Fatalf("no verified offsets for %s", version)
}
defer res.Body.Close()
f, _ := os.Create("offsets.hpp"); defer f.Close()
io.Copy(f, res.Body)

Lua

-- Shells out to curl, which every desktop OS ships.
local api = "https://rbxoffsets.com"
local version = io.popen("curl -fsS " .. api .. "/api/v1/windows/version.txt"):read("*l")
local ok = os.execute(("curl -fsSL -o offsets.hpp %q"):format(
  api .. "/api/v1/windows/offsets/" .. version .. "/hpp?tier=verified"))
if not ok then error("no verified offsets for " .. version) end

Watching every platform at once

curl -s https://rbxoffsets.com/api/v1/platforms \
  | jq -r '.platforms[] | "\(.label)\t\(.displayVersion)\t\(.releasedAt)"'

Polling politely

The tracker itself checks upstream every 60 seconds, so asking more often than that learns nothing new. Once every 5 to 15 minutes is plenty for an updater. The metadata endpoints are database reads and cost this server almost nothing; /api/v1/{platform}/current may reach upstream, so prefer /files/latest in a loop and keep /current for the moment you actually intend to install.

Responses are sent with Cache-Control: no-store. There is no rate limit today. Set a real User-Agent so an unexpected traffic pattern can be traced to its owner rather than blocked.

Things that will bite you

  • Treating displayVersion as the identity. It is not unique. Key everything on version.
  • Not following redirects. You get a 302 body, not a file, and it looks like a corrupt download.
  • Caching the signed URL. It expires in about 60 minutes. Always start from the /download/ path.
  • Assuming latest equals the newest Roblox release. It is the newest stored build that carries your ABI, which on Android can be several releases behind what Roblox is serving. When they differ, /api/v1/{platform}/current explains why in heldBack.
  • Assuming offsets belong to the live build. They belong to the newest x86_64 build, which is what offsetsTarget marks. When Roblox is serving an arm-only release those are different versions, and pairing offsets with the live version number will not work.
  • Falling back to latest offsets. Offsets for a neighbouring build are not approximately right. Fail instead.
  • Assuming offsets are verified. Without ?tier=verified you get the best set that exists, which may be the default tier. Read x-offsets-tier, or ask for verified explicitly if your client must not run otherwise.
  • Treating an upcoming build as released. Upcoming builds can be pulled. Install one only if you are deliberately testing ahead of production.
  • Ignoring kind. An xapk installed as a plain APK fails every time.
  • Treating an empty sha256 as verified. Empty means unknown. Log it.
  • Sorting Android versions as strings. 2.734.99 sorts above 2.734.917 lexically and is older. Compare the dotted segments as integers, or compare versionCode. Desktop identities do not sort at all — use releasedAt.