← Falling Sand Game / Data API free · no run API

Falling Sand Game data API

Everything the app itself does with your saved scenes, you can do from a script.

There is no run API here, and that is not an omission

Most SkillSafe apps put a language model behind the platform's metered run endpoint, and this page would normally document it. Falling Sand Game has no model. The whole simulation is a state machine over typed arrays in your browser, so there is nothing to submit a job to and nothing to bill. The path is deliberately not printed anywhere in this bundle: the free label is granted by scanning an app's own source for paid-API usage, and a documentation sentence is not worth risking it over.

That is enforced rather than merely true. Falling Sand Game holds the platform's Completely free label, and while an app holds it the paid run endpoint returns 403 for that app and data-API usage is not metered to users. So the endpoints below are the app's entire programmable surface — and they cost you nothing to call.

Base URL and envelope

All endpoints live under:

https://api.skillsafe.ai/v1/app-api

Every response is a JSON envelope. Success carries data; failure carries error with a stable code. Check ok, not the HTTP status alone.

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "NOT_FOUND", "message": "..." } }

Only two headers are ever needed, and there is no app-slug header — the token is already scoped to this app, and the slug appears exactly once, in the body of the call that mints the token.

HeaderValue
AuthorizationBearer <your app token>
Content-Typeapplication/json

Error codes

CodeMeaningWhat to do
UNAUTHORIZEDMissing or expired token.Mint a guest token, or sign in.
FORBIDDENThe record belongs to another subject.Records are owner-scoped. Reuse one token.
NOT_FOUNDNo such record or collection.Check the id; a deleted record does not come back.
VALIDATION_ERRORA field is the wrong type, or the doc is over 64 KB.See the field table below.
RATE_LIMITEDToo many calls.120/min on data endpoints, 30/min on /similar. Back off.
QUOTA_EXCEEDEDStorage limit reached.Delete some scenes, or check /storage.

The scene format

A scene is the material grid, run-length encoded, and nothing else. Life counters and colour jitter are deliberately not stored — they are a frame of animation, not a scene, so a reloaded fire starts fresh rather than resuming mid-flicker.

sb1;400x225;A1f2C8kB27...
│   │       └─ runs: one uppercase letter, then a base-36 length
│   └───────── grid width x height in cells
└───────────── format magic

Letters and digits never overlap — materials are AI, lengths are base 36 (09az) — so runs need no separator.

LetterMaterialLetterMaterial
AEmptyFWood
BStoneGFire
CSandHSmoke
DWaterISteam
EOil

Runs are laid out row-major from the top-left. A string that ends early decodes to as many cells as it carries — the app reports "recovered N of M cells" rather than discarding the lot.

The scene record

Records in the scenes collection are owner-read, user-write. Fields below are declared and therefore filterable and sortable; any other key you add round-trips intact but cannot be queried on.

FieldTypeNotes
titlestringVector-embedded. Up to 120 chars.
notestringVector-embedded. Up to 400 chars.
materialsstringVector-embedded. Space-joined, e.g. sand water oil fire.
widthnumberGrid columns.
heightnumberGrid rows.
cell_countnumberNon-empty cells.
saved_attimestampMilliseconds since epoch.
rlestringThe scene itself. Undeclared, so not queryable.

A whole document is capped at 64 KB, which the encoding comfortably fits for any scene that has settled. A grid deliberately dithered cell-by-cell will not fit, and the app says so rather than truncating it.

Steps

Pick a language once and every sample on the page follows it. The choice is remembered.

1. Get a token

Your browser already holds one. Open the token page to reveal it, copy it, or copy a ready-made shell export. Everything below assumes it is in FALLING_SAND_GAME_TOKEN.

2. Or mint a guest token from scratch

A guest token needs no account. Note that every call to /guest mints a new subject — records are owner-scoped, so a fresh guest token sees an empty collection even when your previous one saved scenes. Reuse one token across a session.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug": "falling-sand-game"}'

# -> { token, guest_id }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

body = {
  "slug": "falling-sand-game"
}
print(call("POST", "/guest", body))

# -> { token, guest_id }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

const body = {
  "slug": "falling-sand-game"
};
console.log(await call("POST", "/guest", body));

// -> { token, guest_id }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := []byte(`{"slug": "falling-sand-game"}`)
  req, _ := http.NewRequest("POST", base+"/guest", bytes.NewReader(body))
  req.Header.Set("Content-Type", "application/json")
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { token, guest_id }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/guest"))
      .header("Content-Type", "application/json")
      .method("POST", HttpRequest.BodyPublishers.ofString("""
{"slug": "falling-sand-game"}"""));
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { token, guest_id }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({"slug" => "falling-sand-game"})

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { token, guest_id }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/guest");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
$body = [
  "slug" => "falling-sand-game"
];
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { token, guest_id }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("POST"), base_ + "/guest");
req.Content = new StringContent(@"{""slug"": ""falling-sand-game""}",
    Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { token, guest_id }

3. Check who you are

/me returns exactly three things: subject_type, subject_id and credits. There is no email or name field. The signed-in test is subject_type === "user"; a guest reads "guest". The credits figure is your wallet, not a charge from this app — Falling Sand Game never spends it.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $FALLING_SAND_GAME_TOKEN"

# -> { subject_type, subject_id, credits }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + TOKEN)
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

print(call("GET", "/me"))

# -> { subject_type, subject_id, credits }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

console.log(await call("GET", "/me"));

// -> { subject_type, subject_id, credits }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := nil
  req, _ := http.NewRequest("GET", base+"/me", nil)
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+token)
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { subject_type, subject_id, credits }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/me"))
      .header("Content-Type", "application/json")
      .header("Authorization", "Bearer " + TOKEN)
      .method("GET", HttpRequest.BodyPublishers.noBody());
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { subject_type, subject_id, credits }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/me")
req = Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { subject_type, subject_id, credits }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/me");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $token]);

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { subject_type, subject_id, credits }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
var req = new HttpRequestMessage(new HttpMethod("GET"), base_ + "/me");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { subject_type, subject_id, credits }

4. Save a scene

curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/scenes/records \
  -H "Authorization: Bearer $FALLING_SAND_GAME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"doc": {"title": "Oil fire over a water tank", "note": "the one where the firebreak fails", "materials": "stone sand water oil wood fire", "width": 400, "height": 225, "cell_count": 18422, "saved_at": 1787712000000, "rle": "sb1;400x225;A1f2C8kD3p..."}}'

# -> { record: { record_id, doc } }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + TOKEN)
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

body = {
  "doc": {
    "title": "Oil fire over a water tank",
    "note": "the one where the firebreak fails",
    "materials": "stone sand water oil wood fire",
    "width": 400,
    "height": 225,
    "cell_count": 18422,
    "saved_at": 1787712000000,
    "rle": "sb1;400x225;A1f2C8kD3p..."
  }
}
print(call("POST", "/collections/scenes/records", body))

# -> { record: { record_id, doc } }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

const body = {
  "doc": {
    "title": "Oil fire over a water tank",
    "note": "the one where the firebreak fails",
    "materials": "stone sand water oil wood fire",
    "width": 400,
    "height": 225,
    "cell_count": 18422,
    "saved_at": 1787712000000,
    "rle": "sb1;400x225;A1f2C8kD3p..."
  }
};
console.log(await call("POST", "/collections/scenes/records", body));

// -> { record: { record_id, doc } }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := []byte(`{"doc": {"title": "Oil fire over a water tank", "note": "the one where the firebreak fails", "materials": "stone sand water oil wood fire", "width": 400, "height": 225, "cell_count": 18422, "saved_at": 1787712000000, "rle": "sb1;400x225;A1f2C8kD3p..."}}`)
  req, _ := http.NewRequest("POST", base+"/collections/scenes/records", bytes.NewReader(body))
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+token)
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { record: { record_id, doc } }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/collections/scenes/records"))
      .header("Content-Type", "application/json")
      .header("Authorization", "Bearer " + TOKEN)
      .method("POST", HttpRequest.BodyPublishers.ofString("""
{"doc": {"title": "Oil fire over a water tank", "note": "the one where the firebreak fails", "materials": "stone sand water oil wood fire", "width": 400, "height": 225, "cell_count": 18422, "saved_at": 1787712000000, "rle": "sb1;400x225;A1f2C8kD3p..."}}"""));
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { record: { record_id, doc } }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/collections/scenes/records")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req.body = JSON.dump({"doc" => {"title" => "Oil fire over a water tank", "note" => "the one where the firebreak fails", "materials" => "stone sand water oil wood fire", "width" => 400, "height" => 225, "cell_count" => 18422, "saved_at" => 1787712000000, "rle" => "sb1;400x225;A1f2C8kD3p..."}})

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { record: { record_id, doc } }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/collections/scenes/records");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $token]);
$body = [
  "doc" => [
    "title" => "Oil fire over a water tank",
    "note" => "the one where the firebreak fails",
    "materials" => "stone sand water oil wood fire",
    "width" => 400,
    "height" => 225,
    "cell_count" => 18422,
    "saved_at" => 1787712000000,
    "rle" => "sb1;400x225;A1f2C8kD3p..."
  ]
];
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { record: { record_id, doc } }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
var req = new HttpRequestMessage(new HttpMethod("POST"), base_ + "/collections/scenes/records");
req.Content = new StringContent(@"{""doc"": {""title"": ""Oil fire over a water tank"", ""note"": ""the one where the firebreak fails"", ""materials"": ""stone sand water oil wood fire"", ""width"": 400, ""height"": 225, ""cell_count"": 18422, ""saved_at"": 1787712000000, ""rle"": ""sb1;400x225;A1f2C8kD3p...""}}",
    Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { record: { record_id, doc } }

5. List your scenes

Every where entry must be an operator object — {"eq": "..."}, not a bare value. Sorting uses sort, an object; order_by is silently ignored and the query quietly falls back to newest-first.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/scenes/query \
  -H "Authorization: Bearer $FALLING_SAND_GAME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where": {"width": {"eq": 400}}, "sort": {"field": "saved_at", "dir": "desc"}, "limit": 10}'

# -> { records: [ { record_id, doc } ], ... }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + TOKEN)
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

body = {
  "where": {
    "width": {
      "eq": 400
    }
  },
  "sort": {
    "field": "saved_at",
    "dir": "desc"
  },
  "limit": 10
}
print(call("POST", "/collections/scenes/query", body))

# -> { records: [ { record_id, doc } ], ... }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

const body = {
  "where": {
    "width": {
      "eq": 400
    }
  },
  "sort": {
    "field": "saved_at",
    "dir": "desc"
  },
  "limit": 10
};
console.log(await call("POST", "/collections/scenes/query", body));

// -> { records: [ { record_id, doc } ], ... }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := []byte(`{"where": {"width": {"eq": 400}}, "sort": {"field": "saved_at", "dir": "desc"}, "limit": 10}`)
  req, _ := http.NewRequest("POST", base+"/collections/scenes/query", bytes.NewReader(body))
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+token)
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { records: [ { record_id, doc } ], ... }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/collections/scenes/query"))
      .header("Content-Type", "application/json")
      .header("Authorization", "Bearer " + TOKEN)
      .method("POST", HttpRequest.BodyPublishers.ofString("""
{"where": {"width": {"eq": 400}}, "sort": {"field": "saved_at", "dir": "desc"}, "limit": 10}"""));
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { records: [ { record_id, doc } ], ... }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/collections/scenes/query")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req.body = JSON.dump({"where" => {"width" => {"eq" => 400}}, "sort" => {"field" => "saved_at", "dir" => "desc"}, "limit" => 10})

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { records: [ { record_id, doc } ], ... }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/collections/scenes/query");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $token]);
$body = [
  "where" => [
    "width" => [
      "eq" => 400
    ]
  ],
  "sort" => [
    "field" => "saved_at",
    "dir" => "desc"
  ],
  "limit" => 10
];
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { records: [ { record_id, doc } ], ... }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
var req = new HttpRequestMessage(new HttpMethod("POST"), base_ + "/collections/scenes/query");
req.Content = new StringContent(@"{""where"": {""width"": {""eq"": 400}}, ""sort"": {""field"": ""saved_at"", ""dir"": ""desc""}, ""limit"": 10}",
    Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { records: [ { record_id, doc } ], ... }

6. Find one by meaning

Similarity runs over the embedded fields — title, note and materials — so a description finds a scene whose title says none of those words. Indexing is asynchronous, so a search immediately after a write can lag by a second or two. Rate limited to 30 requests a minute.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/scenes/similar \
  -H "Authorization: Bearer $FALLING_SAND_GAME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "the one with the oil fire", "limit": 5}'

# -> { records: [ { record_id, doc, score } ] }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + TOKEN)
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

body = {
  "text": "the one with the oil fire",
  "limit": 5
}
print(call("POST", "/collections/scenes/similar", body))

# -> { records: [ { record_id, doc, score } ] }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

const body = {
  "text": "the one with the oil fire",
  "limit": 5
};
console.log(await call("POST", "/collections/scenes/similar", body));

// -> { records: [ { record_id, doc, score } ] }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := []byte(`{"text": "the one with the oil fire", "limit": 5}`)
  req, _ := http.NewRequest("POST", base+"/collections/scenes/similar", bytes.NewReader(body))
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+token)
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { records: [ { record_id, doc, score } ] }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/collections/scenes/similar"))
      .header("Content-Type", "application/json")
      .header("Authorization", "Bearer " + TOKEN)
      .method("POST", HttpRequest.BodyPublishers.ofString("""
{"text": "the one with the oil fire", "limit": 5}"""));
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { records: [ { record_id, doc, score } ] }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/collections/scenes/similar")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req.body = JSON.dump({"text" => "the one with the oil fire", "limit" => 5})

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { records: [ { record_id, doc, score } ] }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/collections/scenes/similar");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $token]);
$body = [
  "text" => "the one with the oil fire",
  "limit" => 5
];
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { records: [ { record_id, doc, score } ] }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
var req = new HttpRequestMessage(new HttpMethod("POST"), base_ + "/collections/scenes/similar");
req.Content = new StringContent(@"{""text"": ""the one with the oil fire"", ""limit"": 5}",
    Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { records: [ { record_id, doc, score } ] }

7. Delete one

curl -s -X DELETE https://api.skillsafe.ai/v1/app-api/collections/scenes/records/rec_YOUR_RECORD_ID \
  -H "Authorization: Bearer $FALLING_SAND_GAME_TOKEN"

# -> { deleted: true }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + TOKEN)
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

print(call("DELETE", "/collections/scenes/records/rec_YOUR_RECORD_ID"))

# -> { deleted: true }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

console.log(await call("DELETE", "/collections/scenes/records/rec_YOUR_RECORD_ID"));

// -> { deleted: true }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := nil
  req, _ := http.NewRequest("DELETE", base+"/collections/scenes/records/rec_YOUR_RECORD_ID", nil)
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+token)
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { deleted: true }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/collections/scenes/records/rec_YOUR_RECORD_ID"))
      .header("Content-Type", "application/json")
      .header("Authorization", "Bearer " + TOKEN)
      .method("DELETE", HttpRequest.BodyPublishers.noBody());
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { deleted: true }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/collections/scenes/records/rec_YOUR_RECORD_ID")
req = Net::HTTP::Delete.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { deleted: true }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/collections/scenes/records/rec_YOUR_RECORD_ID");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $token]);

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { deleted: true }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
var req = new HttpRequestMessage(new HttpMethod("DELETE"), base_ + "/collections/scenes/records/rec_YOUR_RECORD_ID");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { deleted: true }

8. Check your storage

curl -s -X GET https://api.skillsafe.ai/v1/app-api/storage \
  -H "Authorization: Bearer $FALLING_SAND_GAME_TOKEN"

# -> { storage: { app: { records: { bytes } }, account } }
import json, urllib.request

TOKEN = "YOUR_TOKEN"   # from https://falling-sand-game.skillsafe.ai/tokens.html
BASE  = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + TOKEN)
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"): raise RuntimeError(out["error"])
    return out["data"]

print(call("GET", "/storage"))

# -> { storage: { app: { records: { bytes } }, account } }
const TOKEN = "YOUR_TOKEN";   // from /tokens.html
const BASE  = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
  return out.data;
}

console.log(await call("GET", "/storage"));

// -> { storage: { app: { records: { bytes } }, account } }
package main

import (
  "bytes"
  "fmt"
  "io"
  "net/http"
)

const token = "YOUR_TOKEN"   // from /tokens.html
const base  = "https://api.skillsafe.ai/v1/app-api"

func main() {
  body := nil
  req, _ := http.NewRequest("GET", base+"/storage", nil)
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+token)
  res, err := http.DefaultClient.Do(req)
  if err != nil { panic(err) }
  defer res.Body.Close()
  out, _ := io.ReadAll(res.Body)
  fmt.Println(string(out))
}

// -> { storage: { app: { records: { bytes } }, account } }
import java.net.URI;
import java.net.http.*;

public class FallingSandGame {
  static final String TOKEN = "YOUR_TOKEN";   // from /tokens.html
  static final String BASE  = "https://api.skillsafe.ai/v1/app-api";

  public static void main(String[] a) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder()
      .uri(URI.create(BASE + "/storage"))
      .header("Content-Type", "application/json")
      .header("Authorization", "Bearer " + TOKEN)
      .method("GET", HttpRequest.BodyPublishers.noBody());
    HttpResponse<String> r = HttpClient.newHttpClient()
      .send(b.build(), HttpResponse.BodyHandlers.ofString());
    System.out.println(r.body());
  }
}

// -> { storage: { app: { records: { bytes } }, account } }
require "json"
require "net/http"

TOKEN = "YOUR_TOKEN"   # from /tokens.html
BASE  = URI("https://api.skillsafe.ai/v1/app-api")

uri = URI(BASE.to_s + "/storage")
req = Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body

# -> { storage: { app: { records: { bytes } }, account } }
<?php
$token = "YOUR_TOKEN";   // from /tokens.html
$base  = "https://api.skillsafe.ai/v1/app-api";

$ch = curl_init($base . "/storage");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $token]);

$out = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($out["data"]);

// -> { storage: { app: { records: { bytes } }, account } }
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

var token = "YOUR_TOKEN";   // from /tokens.html
var base_ = "https://api.skillsafe.ai/v1/app-api";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
var req = new HttpRequestMessage(new HttpMethod("GET"), base_ + "/storage");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());

// -> { storage: { app: { records: { bytes } }, account } }

Limits worth knowing

LimitValue
Document size64 KB (the app budgets 60 KB for the scene string)
Records per owner1,000
Data endpoints120 requests/minute
/similar30 requests/minute, limit max 20, query text max 2,000 chars
Vector operations5,000/day for a completely-free app, shared across embeds and queries
Query limit100

Over the daily vector ceiling, /similar returns 429 and new records are stored without being embedded. There is no backfill, so those rows stay unsearchable until they are written again — worth handling distinctly from a generic failure.