4. Extract player data (name, XP, skills)

This page turns the subscribe steps into actual column values: player entity_id, username, login fields, and per-skill XP / level. Two workable paths: the public HTTP cache (/player), or a WebSocket subscription you decode yourself.

Which tables hold the columns

BitCraft spreads “a player” across several tables. You join them on entity_id (the player PK).

Table Columns you care about Notes
player_username_state entity_id (u64 PK), username (string) Display name. Search here for name substring.
player_state entity_id, sign_in_timestamp (i32 unix s), session_start_timestamp, signed_in (bool) sign_in_timestamp is what tools call last login. 0 means unknown (cleared on logout).
mobile_entity_state entity_id, timestamp (u64 unix ms), location fields… Last movement/position update. Survives logout; relay-cache exposes it as last_active_timestamp (unix s).
experience_state entity_id, experience_stacks experience_stacks is a list of (skill_id: i32, xp: i32) pairs — one row per player.
skill_desc id, name, title, max_level Catalog: map skill_id"Forestry", etc. Small; subscribe once.
player_username_state.entity_id = P │ username ├─ player_state[P] → signed_in, sign_in_timestamp ├─ mobile_entity_state[P] → timestamp (last active; survives logout) ├─ experience_state[P] → [(skill_id, xp), …] │ └─ skill_desc[skill_id] → name └─ level = xp_to_level(xp) (threshold table; relay-cache vendors it) joined player view { entity_id, username, region?, signed_in?, last_login?, last_active?, skills: [{name, level, xp}] }

Path A — HTTP cache (easiest)

relay-cache already subscribed to those tables on every region and performs the join. Use this when you want column values without speaking BSATN.

  1. Search by name GET https://relay.bitcraftsync.app/player?name=maple returns an array of { entity_id, username, region, signed_in?, last_login_timestamp?, last_active_timestamp? }. Prefer ≥2 characters.
  2. Pick an entity_id IDs are JSON strings (JS-safe u64). Example: Maplesugar → 1297036692699996362 on region 14.
  3. Fetch skills GET …/player/<id>/skills returns the joined skill list with skill_id, name, level, xp.

Example response shapes

# GET /player?name=maple  (truncated)
[
  {
    "entity_id": "1297036692699996362",
    "username": "Maplesugar",
    "region": 14,
    "signed_in": false,
    "last_active_timestamp": 1784779859
  }
]

# GET /player/1297036692699996362/skills  (truncated)
{
  "player": {
    "entity_id": "1297036692699996362",
    "username": "Maplesugar",
    "region": 14
  },
  "skills": [
    { "skill_id": 2, "name": "Forestry", "level": 58, "xp": 2313100 },
    { "skill_id": 14, "name": "Foraging", "level": 88, "xp": 63954454 }
  ]
}

Runnable clients

#!/usr/bin/env python3
"""Print username + top skills for a name search via relay-cache HTTP."""
import json
import sys
import urllib.parse
import urllib.request

BASE = "https://relay.bitcraftsync.app"
NEEDLE = sys.argv[1] if len(sys.argv) > 1 else "maple"


def get(path: str):
    with urllib.request.urlopen(BASE + path, timeout=60) as r:
        return json.load(r)


players = get("/player?name=" + urllib.parse.quote(NEEDLE))
if not players:
    sys.exit(f"no players matching {NEEDLE!r}")

# Prefer an exact (case-insensitive) username match when present.
pick = next(
    (p for p in players if p["username"].lower() == NEEDLE.lower()),
    players[0],
)
entity_id = pick["entity_id"]  # str — keep as str for the URL
print(f"{pick['username']}  entity_id={entity_id}  region={pick['region']}")
print(f"  signed_in={pick.get('signed_in')}  last_login={pick.get('last_login_timestamp')}")

detail = get(f"/player/{entity_id}/skills")
skills = sorted(detail["skills"], key=lambda s: s["xp"], reverse=True)
print(f"  {len(skills)} skills — top 5 by XP:")
for s in skills[:5]:
    print(f"    {s['name']:<20} level={s['level']:<3} xp={s['xp']}")

# Every column on the skills payload:
#   skill_id: int   name: str   level: int   xp: int
// cargo add reqwest serde serde_json tokio --features reqwest/json,tokio/rt-multi-thread,macros
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Player {
    entity_id: String,
    username: String,
    region: u32,
    signed_in: Option<bool>,
    last_login_timestamp: Option<i64>,
}

#[derive(Debug, Deserialize)]
struct Skill {
    skill_id: i32,
    name: String,
    level: i64,
    xp: i64,
}

#[derive(Debug, Deserialize)]
struct PlayerSkills {
    player: Player,
    skills: Vec<Skill>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let needle = std::env::args().nth(1).unwrap_or_else(|| "maple".into());
    let url = format!("https://relay.bitcraftsync.app/player?name={needle}");
    let players: Vec<Player> = reqwest::get(&url).await?.error_for_status()?.json().await?;
    let pick = players
        .iter()
        .find(|p| p.username.eq_ignore_ascii_case(&needle))
        .or_else(|| players.first())
        .ok_or("no match")?;

    println!(
        "{}  entity_id={}  region={}  signed_in={:?}  last_login={:?}",
        pick.username, pick.entity_id, pick.region, pick.signed_in, pick.last_login_timestamp
    );

    let skills_url = format!(
        "https://relay.bitcraftsync.app/player/{}/skills",
        pick.entity_id
    );
    let body: PlayerSkills = reqwest::get(&skills_url)
        .await?
        .error_for_status()?
        .json()
        .await?;
    let mut skills = body.skills;
    skills.sort_by_key(|s| std::cmp::Reverse(s.xp));
    for s in skills.iter().take(5) {
        println!("  {:<20} level={:<3} xp={}", s.name, s.level, s.xp);
    }
    Ok(())
}
curl -sS 'https://relay.bitcraftsync.app/player?name=maple' | jq .
curl -sS 'https://relay.bitcraftsync.app/player/1297036692699996362' | jq .
curl -sS 'https://relay.bitcraftsync.app/player/1297036692699996362/skills' \
  | jq '.skills | sort_by(-.xp) | .[0:5]'

Path B — WebSocket: read the same columns yourself

Use this when you want a live local cache (or columns the HTTP API does not expose). Flow: connect → subscribe the four tables (filtered) → wait for Applied → join on entity_id and print. Python below is the same runnable v1.json join as Subscribe; Rust uses generated SDK bindings.

  1. Generate bindings from the region schema (see Discover schema) so each table is a typed struct with named fields — not opaque bytes.
  2. Subscribe in order (wait for Applied between sets): skill_descplayer_username_stateplayer_stateexperience_state. Catalog first so skill names resolve as XP arrives.
  3. Read fields from the client cache after the last Applied (examples below). Keep applying live updates the same way.
#!/usr/bin/env python3
"""Same runnable join as Subscribe → “Many tables” (v1.json).
   pip install websockets
"""
import asyncio
import json
import websockets

URL = (
    "wss://relay.bitcraftsync.app:3014"
    "/v1/database/relay-mirror-bc14/subscribe?compression=None"
)
ENTITY = 1297036692699996362  # Maplesugar on region 14

QUERIES = [
    "SELECT * FROM skill_desc",
    "SELECT * FROM player_username_state WHERE username = 'Maplesugar'",
    f"SELECT * FROM player_state WHERE entity_id = {ENTITY}",
    f"SELECT * FROM experience_state WHERE entity_id = {ENTITY}",
]

async def recv_json(ws, timeout=180):
    while True:
        msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
        if isinstance(msg, str):
            return json.loads(msg)

async def main():
    async with websockets.connect(
        URL, subprotocols=["v1.json.spacetimedb"], max_size=None
    ) as ws:
        assert "IdentityToken" in await recv_json(ws)  # v1 greeting

        await ws.send(json.dumps({
            "Subscribe": {"request_id": 1, "query_strings": QUERIES}
        }))
        applied = await recv_json(ws)
        isub = applied["InitialSubscription"]

        skill_names = {}
        stacks = []
        for table in isub["database_update"]["tables"]:
            name = table["table_name"]
            for upd in table["updates"]:
                for raw in upd.get("inserts", []):
                    row = json.loads(raw) if isinstance(raw, str) else raw
                    if name == "skill_desc":
                        skill_names[row["id"]] = row["name"]
                    elif name == "player_username_state":
                        print(f"username={row['username']}  entity_id={row['entity_id']}")
                    elif name == "player_state":
                        print(
                            f"  signed_in={row.get('signed_in')}  "
                            f"sign_in_timestamp={row.get('sign_in_timestamp')}"
                        )
                    elif name == "experience_state":
                        stacks = row.get("experience_stacks") or []

        for skill_id, xp in sorted(stacks, key=lambda p: -p[1])[:5]:
            label = skill_names.get(skill_id, "?")
            print(f"  {label:<20} skill_id={skill_id}  xp={xp}")

        # Snapshot done — close the socket (OneOffQuery substitute).
        # For a live cache, keep the connection and apply TransactionUpdates.

asyncio.run(main())
// Assumes `spacetime generate --lang rust` produced module_bindings
// with tables: skill_desc, player_username_state, player_state, experience_state.
use module_bindings::*;
use spacetimedb_sdk::DbContext;
use std::collections::HashMap;

fn skill_names(ctx: &impl DbContext) -> HashMap<i32, String> {
    ctx.db
        .skill_desc()
        .iter()
        .map(|row| (row.id, row.name.clone()))
        .collect()
}

fn dump_player(ctx: &impl DbContext, want_name: &str) {
    let names = skill_names(ctx);
    let Some(user) = ctx
        .db
        .player_username_state()
        .iter()
        .find(|r| r.username.eq_ignore_ascii_case(want_name))
    else {
        println!("no player_username_state row for {want_name}");
        return;
    };
    let id = user.entity_id;
    println!("username={}  entity_id={}", user.username, id);

    if let Some(ps) = ctx.db.player_state().entity_id().find(&id) {
        println!(
            "  signed_in={}  sign_in_timestamp={}  session_start_timestamp={}",
            ps.signed_in, ps.sign_in_timestamp, ps.session_start_timestamp
        );
    }

    if let Some(xp_row) = ctx.db.experience_state().entity_id().find(&id) {
        let mut stacks: Vec<_> = xp_row.experience_stacks.iter().cloned().collect();
        stacks.sort_by_key(|(_skill, xp)| std::cmp::Reverse(*xp));
        println!("  skills (top 5 by xp):");
        for (skill_id, xp) in stacks.into_iter().take(5) {
            let name = names.get(&skill_id).map(|s| s.as_str()).unwrap_or("?");
            println!("    {name}  skill_id={skill_id}  xp={xp}");
        }
    }
}

// Sequential subscribe → dump_player: see Subscribe page (Rust tab).
// Persist `token` from on_connect and pass `.with_token(token)` on reconnect.

What you should see

For a known character on region 14, after the four Applied callbacks, printing columns should look like:

Maplesugar  entity_id=1297036692699996362
  signed_in=False  sign_in_timestamp=…
  Forestry             skill_id=2   xp=2313100
  Foraging             skill_id=14  xp=63954454
  …

If username prints but skills are empty, your experience_state subscribe has not Applied yet (or you joined the wrong entity_id). If skills have XP but names are ?, skill_desc is missing — subscribe it first.

Level from XP

Raw rows only store xp. Skill level is derived from a static threshold table (same one BitJita / relay-cache use). The HTTP /player/<id>/skills response already includes level. If you decode WebSocket rows yourself, either call that endpoint for levels or vendor the same thresholds.