Skip to content

Query

Anyone can ask a SAGL server about itself without joining it. This is how server lists, websites, Discord bots and the game's own server browser find out:

  • the server's name, SAGL version and protocol;
  • who's on, with their scores and pings, or just how many;
  • its branding: colours, and the logo, fetched by its hash;
  • the gamemode's own fields: a description, links (website, Discord), tags, and values it changes as it runs.

The query works over UDP on the game port, so it needs nothing else opened. If you turn it on, the same JSON is also served over HTTP for websites.

const server = new Server({
  name: "Example RP",
  query: {
    description: "Los Santos roleplay. Whitelisted; apply on the website.",
    gamemode: "Roleplay",
    language: "en",
    tags: ["roleplay", "whitelist"],
    links: { website: "https://example.com", discord: "https://discord.gg/example" },
    http: { port: 8080 },   // optional: GET http://host:8080/ for websites
  },
});

// Values: the gamemode's own, changed whenever it likes.
server.query.set("map", "Los Santos");
setInterval(() => server.query.set("copsOnDuty", cops().length), 10_000);

To ask a server from Node (a server list, a bot), use @sagl/query. The server SDK also exports the same queryServer.


server.query

info

server.query.info = { description, gamemode, language, tags, links };
server.query.update({ description: "Event tonight at 8!" });   // change some, keep the rest
Field
description A line or two about the server (up to 1000 characters).
gamemode What's played: "Roleplay", "Freeroam", ...
language The players' language(s), e.g. "en".
tags Up to 32 tags, for server lists to filter by.
links Web links by name: website, discord, store, forum, rules, ... Each must be an http(s) URL.

set(key, value), get(key), delete(key), values

Your own values, shown under values. A value is a string (up to 256 characters), a number, a boolean or null, and there can be up to 64 of them. Change them as often as you like: a query always sees the latest.

server.query.set("event", "Drag race at the airport");
server.query.delete("event");

players

How much a query shows of who's on:

  • "list" (the default): every player's id, name, score and ping.
  • "count": only how many are on.
  • "none": nothing.
server.query.players = "count";

The score is player.score, the same one the in-game player list shows.

result

What a query gets right now, as an object.

http(options), closeHttp()

Serves the query over HTTP as well, for websites:

Request
GET / or GET /query The same JSON a UDP query gets.
GET /logo The logo (PNG or JPEG), with its SHA-256 as the ETag.

Any site may fetch these (CORS: Access-Control-Allow-Origin: *).

await server.query.http({ port: 8080 });            // or the `query.http` option
await server.query.http({ port: 8080, host: "127.0.0.1" });  // behind your own web server

To serve it over HTTPS, put it behind a web server such as nginx or Caddy.


What a query says

{
  "query": 1,
  "name": "Example RP",
  "version": "0.2.0",
  "protocol": 102,
  "players": {
    "online": 2, "max": 50,
    "list": [{ "id": 0, "name": "Thomas", "score": 12, "ping": 31 }, { "id": 3, "name": "Sam", "score": 4, "ping": 58 }]
  },
  "branding": {
    "logo": { "sha256": "0ca18ac0…", "size": 48211 },
    "primary": "#ff8800", "secondary": null, "showName": false
  },
  "description": "Los Santos roleplay. Whitelisted; apply on the website.",
  "gamemode": "Roleplay",
  "language": "en",
  "tags": ["roleplay", "whitelist"],
  "links": { "website": "https://example.com", "discord": "https://discord.gg/example" },
  "values": { "map": "Los Santos", "copsOnDuty": 3 }
}
  • players is left out with players: "none", and has no list with "count". A very long list (thousands of players) is cut short and gets "truncated": true.
  • branding.logo is the branding logo's SHA-256 and size, or null. Fetch it by its hash, and cache it by its hash too: it only changes when the hash does.
  • Colours are "#rrggbb", or null for SAGL's own.
  • The gamemode's fields (description and the rest) are there only if it set them.
  • A client can join only if its protocol matches the server's.

@sagl/query

A small Node.js package (no dependencies, no native code) for asking servers:

npm install @sagl/query
import { queryServer } from "@sagl/query";

const { info, ping, logo } = await queryServer("play.example.com", 7777, { logo: true });
console.log(`${info.name}: ${info.players?.online}/${info.players?.max}, ${ping} ms`);

queryServer(host, port = 7777, { timeout = 1500, logo = false }) resolves to:

  • info: the JSON above, typed as QueryResult.
  • ping: the round trip, in ms.
  • logo: the logo's bytes, with logo: true, checked against its hash.

It rejects if the server doesn't answer.

sagl-query

The same from a terminal. It's built with the server, next to sagl-ping:

sagl-query play.example.com 7777                 # prints the JSON; the round trip on stderr
sagl-query play.example.com 7777 --logo logo.png

The format, for other languages

The query uses UDP on the game port. Integers are little-endian.

Requests are always exactly 64 bytes, zero-padded:

Offset
0 "SAGQ"
4 u8 version: 1
5 u8 kind: 1 the info, 2 a piece of the logo
6 u16 0
8 u32 token: 0 the first time, then the one from the challenge
12 u32 the logo: the offset to read from

Replies start with "SAGR", u8 version 1, u8 kind, u16 0. What follows depends on the kind:

  • Challenge (kind 0): u32 token. The server sends this to a request without a valid token. Send the request again with the token. A token lasts at least 30 seconds, and a new challenge means it has run out.
  • Info (kind 1): u32 serial, u32 total length, u16 part, u16 parts, then that part's bytes of the JSON. Put the parts with one serial together in order. A new serial means the JSON changed in between, so start again from that one.
  • Logo (kind 2): u32 total size (0: no logo), u32 offset, the logo's 32-byte SHA-256, then up to 1024 bytes from that offset. Ask for each offset in turn until you have them all, and check the hash.

There are two safeguards against abuse. The challenge is smaller than the request that triggers it, so the query can't be used to flood someone else with spoofed requests. And replies to each IP are limited to 256 KB a second.

Nothing in a reply is sent reliably. If a reply doesn't come, ask again.