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. |
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.
-
Search by name
GET https://relay.bitcraftsync.app/player?name=maplereturns an array of{ entity_id, username, region, signed_in?, last_login_timestamp?, last_active_timestamp? }. Prefer ≥2 characters. -
Pick an
entity_idIDs are JSON strings (JS-safe u64). Example: Maplesugar →1297036692699996362on region 14. -
Fetch skills
GET …/player/<id>/skillsreturns the joined skill list withskill_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.
- Generate bindings from the region schema (see Discover schema) so each table is a typed struct with named fields — not opaque bytes.
-
Subscribe in order (wait for Applied between sets):
skill_desc→player_username_state→player_state→experience_state. Catalog first so skill names resolve as XP arrives. - 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.