Examples
These examples follow one exchange from start to finish. Tunde plays a game set in Lagos (lagos.example); Lena plays a game set in Berlin (berlin.example). Tunde messages Lena, looks her up, checks what a bar near her offers, visits it and returns home.
Each game keeps its own models and its own names for things. SGIP only defines how they are described when they leave the game: words from the vocabulary, stats on a 0–100 scale, and money in minor units. The code is TypeScript without a framework. Its structure is one way to organise a game's side of the protocol, not a requirement; only the messages on the wire are specified.
The helpers it imports from ./sgip.ts (envelope, callHub, checkSignature, parseSignature, hubKey, passportKeyId, verifyPassport) are the ones built in the Next.js walkthrough.
1. The contract
The types mirror the schemas. Game is what each game implements: two required methods and four optional ones: three for operations and welcome for visitors arriving.
// contract.ts
// The shapes defined in spec/schemas, and the interface a game implements.
import type { Envelope, Passport } from './sgip.ts';
/** Integers 0–100, 100 best. Omit stats the game does not model. */
export type Stats = Partial<Record<'energy' | 'hunger' | 'mood' | 'social' | 'hygiene' | 'fitness' | 'fun' | 'bladder', number>>;
/** Amount in minor units. */
export type Money = { amount: number; currency: string };
export type PlayerCard = { player: string; sub: string; display_name: string; avatar?: { color: string } };
export type PlaceEntry = { id: string; name: string; kind: string; lat?: number; lng?: number; guests_allowed?: boolean };
export type ActionEntry = { id: string; verb: string; label: string; duration_minutes: number; price: Money; pays: Money | null; effects: Stats };
export type VisitReport = { visit_id: string; player: string; reason: 'left' | 'idle' | 'removed' | 'expired'; duration_s?: number; souvenirs?: Stats; activities?: { verb: string; count: number }[] };
/** Required: lookup and deliver. The inbox declares an optional op only when its method exists. */
export interface Game {
platform: string;
lookup(address: string): PlayerCard | null; // player.lookup@1
deliver(to: string, from: string, text: string): boolean; // message.send@1
places?(): PlaceEntry[]; // world.places@1
actions?(place: string): ActionEntry[] | null; // action.list@1
welcome?(passport: Passport): void; // visitor arrival
bringHome?(report: VisitReport): void; // visit.end@1
}
export type { Envelope, Passport };2. The inbox
The inbox is written once against Game. It verifies the hub's signature, rejects reused nonces, returns the stored reply for a repeated envelope id, and passes each operation to the matching method. A game declares an optional operation by implementing its method.
// inbox.ts
// The inbox, written once against the Game interface.
import { checkSignature, hubKey, parseSignature, type Reply } from './sgip.ts';
import type { Envelope, Game, VisitReport } from './contract.ts';
/** The required ops, plus one for each optional method the game implements. */
export function capabilities(game: Game): string[] {
return [
'hub.ping@1', 'player.lookup@1', 'message.send@1',
...(game.places ? ['world.places@1'] : []),
...(game.actions ? ['action.list@1'] : []),
...(game.bringHome ? ['visit.end@1'] : []),
];
}
/** Answers one operation from the game. */
export function answer(game: Game, { id, op, from, to, payload }: Envelope): Reply {
const ok = (result: Record<string, unknown>): Reply => ({ ok: true, id, result });
const no = (code: string, message: string): Reply => ({ ok: false, id, error: { code, message, retryable: false } });
if (!capabilities(game).includes(op)) {
return no('op_unsupported', `${op} is not supported here.`);
}
switch (op) {
case 'hub.ping@1':
return ok({ platform: game.platform, time: new Date().toISOString().replace(/\.\d+Z$/, 'Z') });
case 'player.lookup@1': {
const card = game.lookup(String(payload.player));
return card ? ok(card) : no('not_found', `No player ${payload.player}`);
}
case 'message.send@1':
return game.deliver(String(to.player), String(from.player), String(payload.text))
? ok({ delivered: true })
: no('not_found', `No player ${to.player}`);
case 'world.places@1':
return ok({ places: game.places!() });
case 'action.list@1': {
const actions = game.actions!(String(payload.place));
return actions ? ok({ place: payload.place, actions }) : no('not_found', `No place ${payload.place}`);
}
case 'visit.end@1':
game.bringHome!(payload as VisitReport);
return ok({ returned: true });
}
return no('op_unsupported', `${op} is not supported here.`);
}
const STATUS: Record<string, number> = { bad_signature: 401, expired: 401, not_found: 404, op_unsupported: 422, invalid_payload: 422 };
const nonces = new Set<string>(); // in production: a database table, kept 10 minutes
const replies = new Map<string, Reply>(); // in production: a database table, kept 24 hours
/** POST /sgip/inbox. Verifies the request, then answers it. */
export async function inbox(game: Game, request: Request): Promise<Response> {
const body = await request.text();
const header = request.headers.get('SGIP-Signature');
const check = checkSignature(header, 'POST', new URL(request.url).pathname, body, (await hubKey(parseSignature(header)?.keyId)) ?? '');
const nonce = parseSignature(header)?.nonce ?? '';
if (check !== 'ok' || nonces.has(nonce)) {
return Response.json({ ok: false, id: '', error: { code: check === 'ok' ? 'bad_signature' : check, message: 'Not signed by the hub.', retryable: false } }, { status: 401 });
}
nonces.add(nonce);
const message: Envelope = JSON.parse(body);
const reply = replies.get(message.id) ?? answer(game, message); // a repeated id gets the stored reply
replies.set(message.id, reply);
return Response.json(reply, { status: reply.ok ? 200 : STATUS[reply.error.code] ?? 400 });
}3. The Berlin game
Berlin stores venues by its own categories, prices in euros, and mood changes from -10 to +10. Its mappers translate each of these once: categories to place kinds, activities to verbs, euros to cents, and mood changes to the shared scale. A category without a registered term is sent as an extension (x-berlin.beer_garden) until one is proposed.
// berlin.ts
// A Berlin game. It stores venues by its own categories, prices in euros,
// and mood changes on a -10 to +10 scale. These mappers describe them in SGIP's terms.
import type { ActionEntry, Game, PlaceEntry, PlayerCard, Stats, VisitReport } from './contract.ts';
import type { Passport } from './sgip.ts';
type Category = 'bar' | 'snack_bar' | 'late_shop' | 'nightclub' | 'beer_garden';
type Activity = 'drink' | 'eat' | 'table_football' | 'dancing';
type Item = { key: string; name: string; activity: Activity; minutes: number; priceEuros: number; moodChange: number; energyChange: number };
type Venue = { district: string; slug: string; name: string; category: Category; lat: number; lng: number; acceptsVisitors: boolean; items: Item[] };
type Resident = { handle: string; name: string; color: string; sub: string; discoverable: boolean; inbox: string[] };
/** Categories to place kinds (vocabulary: place kinds). */
const PLACE_KIND: Record<Category, string> = {
bar: 'pub',
snack_bar: 'fast_food',
late_shop: 'convenience',
nightclub: 'club',
beer_garden: 'x-berlin.beer_garden', // no registered term yet
};
/** Activities to verbs (vocabulary: verbs). */
const VERB: Record<Activity, string> = { drink: 'drink', eat: 'eat', table_football: 'play', dancing: 'dance' };
/** Effects on the shared 0–100 scale. */
function effects(item: Item): Stats {
return {
mood: item.moodChange * 5, // -10..10 to -50..50
energy: item.energyChange,
};
}
export function toPlace(venue: Venue, platform: string): PlaceEntry {
return {
id: `place://${platform}/${venue.district}/${venue.slug}`,
name: venue.name,
kind: PLACE_KIND[venue.category],
lat: venue.lat,
lng: venue.lng,
guests_allowed: venue.acceptsVisitors,
};
}
export function toAction(item: Item): ActionEntry {
return {
id: item.key,
verb: VERB[item.activity],
label: item.name,
duration_minutes: item.minutes,
price: { amount: Math.round(item.priceEuros * 100), currency: 'EUR' },
pays: null,
effects: effects(item),
};
}
/** The report sent home when a visitor leaves: summed effects capped at ±20, and a count per verb. */
export function visitReport(passport: Passport, done: ActionEntry[], stayedSeconds: number): VisitReport {
const souvenirs: Stats = {};
const counts = new Map<string, number>();
for (const action of done) {
for (const [stat, change] of Object.entries(action.effects) as [keyof Stats, number][]) {
souvenirs[stat] = Math.max(-20, Math.min(20, (souvenirs[stat] ?? 0) + change));
}
counts.set(action.verb, (counts.get(action.verb) ?? 0) + 1);
}
return {
visit_id: passport.visit_id,
player: passport.player,
reason: 'left',
duration_s: stayedSeconds,
souvenirs,
activities: [...counts].map(([verb, count]) => ({ verb, count })),
};
}
export class BerlinGame implements Game {
platform = 'berlin.example';
guests = new Map<string, Passport>();
private venues: Venue[];
private residents: Resident[];
constructor(venues: Venue[], residents: Resident[]) {
this.venues = venues;
this.residents = residents;
}
lookup(address: string): PlayerCard | null {
const resident = this.resident(address);
if (!resident?.discoverable) {
return null;
}
return { player: address, sub: resident.sub, display_name: resident.name, avatar: { color: resident.color } };
}
deliver(to: string, from: string, text: string): boolean {
const resident = this.resident(to);
resident?.inbox.push(`${from}: ${text}`);
return resident !== undefined;
}
places(): PlaceEntry[] {
return this.venues.map((venue) => toPlace(venue, this.platform));
}
actions(place: string): ActionEntry[] | null {
const venue = this.venues.find((candidate) => toPlace(candidate, this.platform).id === place);
return venue ? venue.items.map(toAction) : null;
}
welcome(passport: Passport): void {
this.guests.set(`${passport.sub}@${passport.home}`, passport);
}
private resident(address: string): Resident | undefined {
return this.residents.find((resident) => `${resident.handle}@${this.platform}` === address);
}
}4. The Lagos game
Lagos stores hunger with 100 meaning starving, and happiness out of 10. It only needs to read stats back when a player returns, so it implements bringHome and leaves the other optional methods out.
// lagos.ts
// A Lagos game. It stores hunger with 100 meaning starving and happiness out of 10.
// When a player comes home, it converts the shared scale back to its own.
import type { Game, PlayerCard, Stats, VisitReport } from './contract.ts';
type Citizen = { handle: string; name: string; color: string; sub: string; discoverable: boolean; energy: number; hungerLevel: number; happiness: number; inbox: string[] };
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
/** Souvenirs onto this game's scales, each change capped at ±20 (spec §7.3). Unmodelled stats are ignored. */
export function applySouvenirs(citizen: Citizen, souvenirs: Stats): void {
const change = (stat: keyof Stats) => clamp(souvenirs[stat] ?? 0, -20, 20);
citizen.energy = clamp(citizen.energy + change('energy'), 0, 100);
citizen.hungerLevel = clamp(citizen.hungerLevel - change('hunger'), 0, 100);
citizen.happiness = clamp(citizen.happiness + Math.round(change('mood') / 10), 0, 10);
}
export class LagosGame implements Game {
platform = 'lagos.example';
private citizens: Citizen[];
constructor(citizens: Citizen[]) {
this.citizens = citizens;
}
lookup(address: string): PlayerCard | null {
const citizen = this.citizen(address);
if (!citizen?.discoverable) {
return null;
}
return { player: address, sub: citizen.sub, display_name: citizen.name, avatar: { color: citizen.color } };
}
deliver(to: string, from: string, text: string): boolean {
const citizen = this.citizen(to);
citizen?.inbox.push(`${from}: ${text}`);
return citizen !== undefined;
}
bringHome({ player, souvenirs = {} }: VisitReport): void {
const citizen = this.citizen(player);
if (citizen) {
applySouvenirs(citizen, souvenirs);
}
}
private citizen(address: string): Citizen | undefined {
return this.citizens.find((citizen) => `${citizen.handle}@${this.platform}` === address);
}
}5. Sending a message
Lagos sends message.send@1 for Tunde. The hub checks the signature and payload, then delivers it to Berlin's inbox, where answer calls berlin.deliver.
const reply = await callHub(envelope(
'message.send@1',
{ platform: 'berlin.example', player: 'lena@berlin.example' },
{ text: 'Hello from Lagos', from_name: 'Tunde' },
'tunde@lagos.example',
));The envelope on the wire:
{
"sgip": "0.1",
"id": "01JA2B3C4D5E6F7G8H9J0K1M2N",
"op": "message.send@1",
"from": { "platform": "lagos.example", "player": "tunde@lagos.example" },
"to": { "platform": "berlin.example", "player": "lena@berlin.example" },
"reply_to": null,
"payload": { "text": "Hello from Lagos", "from_name": "Tunde" }
}The result is { "delivered": true }. If Berlin is offline, the hub holds the message for up to 24 hours and the result is { "queued": true } (spec section 5).
6. Looking up a player
const lena = await callHub(envelope('player.lookup@1', { platform: 'berlin.example' }, { player: 'lena@berlin.example' }));berlin.lookup returns a card only for residents who chose to be discoverable. Everyone else is answered not_found.
{ "player": "lena@berlin.example", "sub": "b7c1e0a4", "display_name": "Lena", "avatar": { "color": "#e76f51" } }7. Listing what a place offers
const offer = await callHub(envelope('action.list@1', { platform: 'berlin.example' }, { place: 'place://berlin.example/kreuzberg/kneipe-am-kanal' }));berlin.actions maps the venue's items through toAction:
{
"place": "place://berlin.example/kreuzberg/kneipe-am-kanal",
"actions": [
{ "id": "beer", "verb": "drink", "label": "Glass of Berliner Pils", "duration_minutes": 40, "price": { "amount": 450, "currency": "EUR" }, "pays": null, "effects": { "mood": 10, "energy": -5 } },
{ "id": "kicker", "verb": "play", "label": "Table football", "duration_minutes": 20, "price": { "amount": 100, "currency": "EUR" }, "pays": null, "effects": { "mood": 5, "energy": -5 } },
{ "id": "dance-night", "verb": "dance", "label": "Dance night", "duration_minutes": 90, "price": { "amount": 800, "currency": "EUR" }, "pays": null, "effects": { "mood": 15, "energy": -15 } }
]
}Lagos models fewer verbs than the vocabulary holds. To display a verb it doesn't model, it follows the term's broader chain until it reaches one it does:
// understand.ts
// Reading a shared word this game doesn't model: follow `broader` until one it does.
type Vocabulary = { terms: { id: string; broader?: string }[] };
export function understand(word: string, known: Set<string>, vocabulary: Vocabulary): string | null {
const broader = new Map(vocabulary.terms.map((term) => [term.id, term.broader]));
for (let current: string | undefined = word; current; current = broader.get(current)) {
if (known.has(current)) {
return current;
}
}
return null;
}const verbs = await (await fetch('https://sgip.dev/vocabulary/verbs.json')).json();
const known = new Set(['eat', 'drink', 'party', 'talk', 'trade']);
understand('drink', known, verbs); // 'drink'
understand('dance', known, verbs); // 'party'
understand('play', known, verbs); // null: show the sender's label as it is8. Visiting
Lagos asks the hub for a passport. It opens Berlin's visit page in an iframe and hands the passport over by postMessage, or, when Berlin can't be embedded, in a new tab with the passport in the URL fragment. Either way the passport never reaches a server log.
const visit = await callHub(envelope('visit.request@1', { platform: 'sgip-hub' }, {
player: 'tunde@lagos.example',
sub: tunde.sub,
host: 'berlin.example',
scopes: ['visit', 'chat', 'act'],
display: { name: 'Tunde', color: '#2e4a6b' },
}, 'tunde@lagos.example'));
if (visit.ok) {
const { launch_url, passport, host_origin, embed } = visit.result as { launch_url: string; passport: string; host_origin: string; embed?: { allowed: boolean } };
if (embed?.allowed === false) {
open(`${launch_url}#passport=${passport}`);
} else {
const frame = document.createElement('iframe');
frame.src = launch_url;
frame.onload = () => frame.contentWindow?.postMessage({ type: 'sgip.passport', token: passport }, host_origin);
document.body.append(frame);
}
}Berlin's visit page posts the passport to its server, which checks it offline with the hub's public key before admitting the visitor:
const key = await hubKey(passportKeyId(token)); // the hub's key, looked up and cached [6.1]
const passport = verifyPassport(token, key ?? ''); // throws if the key, signature, audience or expiry is wrong
berlin.welcome(passport);9. Returning home
Tunde has a beer and stays for the dance night, then leaves after 130 minutes. Berlin builds the report with visitReport and sends it to Lagos.
const report = visitReport(passport, [beer, danceNight].map(toAction), 130 * 60);
await callHub(envelope('visit.end@1', { platform: passport.home, player: passport.player }, report));{
"visit_id": "01JA00000000000000000000V1",
"player": "tunde@lagos.example",
"reason": "left",
"duration_s": 7800,
"souvenirs": { "mood": 20, "energy": -20 },
"activities": [{ "verb": "drink", "count": 1 }, { "verb": "dance", "count": 1 }]
}Mood summed to 25 and was capped at 20. In Lagos, answer calls lagos.bringHome, and applySouvenirs converts back: energy falls by 20 and happiness rises by 2 on its 0–10 scale.
10. Refusals
Every refusal carries a code. retryable tells the sender whether to try again later with the same envelope id.
{
"ok": false,
"id": "01JA2B3C4D5E6F7G8H9J0K1M2N",
"error": { "code": "not_found", "message": "No player lena@berlin.example", "retryable": false }
}| Code | Retryable | Meaning |
|---|---|---|
invalid_payload | No | The envelope or payload doesn't match its schema |
op_unsupported | No | The receiver doesn't declare this operation |
bad_signature | No | Missing or wrong signature, or a reused nonce |
expired | No | The signature timestamp is more than 300 seconds off |
forbidden | No | Signed correctly but not allowed, such as sending for another game's player |
not_found | No | No such player, place, visit or platform |
rate_limited | Yes | Too many requests |
platform_offline | Yes | The receiver couldn't be reached |
insufficient_funds | No | The player's home balance doesn't cover a charge |
limit_reached | No | A charge would pass the trip budget, a daily limit or an inflow limit |
The hub validates every request payload against the schemas before routing it. Replies are returned as the receiver wrote them, so check your results against the operations reference, which also covers the operations not used here.