@fourbtd/sdk
v1.0.1
Published
Framework for Networked Persistent Characters: persistent NPC memory and verifiable identity
Maintainers
Readme
FOUR SDK
Framework for Networked Persistent Characters
@fourbtd/sdk gives game characters persistent memory, evolving relationships, inventory ownership, achievements, and portable identity records.
Capture what happened, recall relevant context in a later session, and commit a cryptographic state root to a wallet-signed Solana checkpoint.
Version 1.0.0 · Unpublished Release Candidate
The complete local workflow works without credentials or network access. HTTP operations require your own backend implementing the contract below.
No hosted 4BTD API, Solana program, paid infrastructure, or universal cross-game protocol is included. Publishing metadata and the license require owner review.
Installation and requirements
After the owner publishes this package:
npm i @fourbtd/sdk
For this unpublished checkout, run npm ci && npm run build && npm pack, then install
the generated fourbtd-sdk-1.0.0.tgz in your application.
Runtime: Node.js 20+; npm 10+ for installation. Use the latest patch of an active Node release for development.
Environments:
local(default),solana-devnet,solana-mainnet.Modern browsers with Fetch, AbortController, structuredClone and Web Crypto in a secure context.
ESM first, with CommonJS, TypeScript declarations and JavaScript source maps.
Zod is the only runtime dependency. No wallet SDK or private key is required.
Five-minute offline quickstart
import { FourBTDClient } from '@fourbtd/sdk';
const fourBtd = new FourBTDClient({
network: 'local',
});
const mira = await fourBtd.characters.create({
name: 'Mira',
owner: 'player-one',
world: 'forest',
traits: ['curious'],
goals: ['Find the lost city'],
metadata: {
faction: 'explorers',
},
});
const event = await fourBtd.events.capture({
characterId: mira.id,
type: 'quest',
content: 'A traveler revealed the path to the lost city.',
importance: 0.9,
idempotencyKey: 'quest-revelation-1',
});
await fourBtd.memory.commit({
characterId: mira.id,
type: 'episodic',
content: event.content,
salience: 0.9,
tags: ['lost-city'],
provenance: [event.id],
});
const recalled = await fourBtd.memory.recall({
characterId: mira.id,
query: 'lost city',
});
console.log(recalled.items.map((memory) => memory.content));From the checkout, npm run examples runs the quickstart and a complete cross-session
integration journey offline. Other examples in examples/ are compiled by npm run typecheck.
Production HTTP client
Copy .env.example to .env for application configuration. The
template defaults to local mode without credentials. The SDK does not automatically
load environment files or read these variables; load them in your application and
pass them explicitly to the client. Treat blank optional values as undefined
(for example, process.env.FOURBTD_API_KEY || undefined). The offline examples
need no .env file. The template is included in the repository, not the npm tarball.
const fourBtd = new FourBTDClient({
apiKey: process.env.FOURBTD_API_KEY,
network: 'solana-devnet',
baseUrl: 'https://your-backend.example.com',
timeoutMs: 15_000,
retries: 2,
backoffMs: 100,
maxBackoffMs: 10_000,
jitter: 0.2,
});An explicit baseUrl is required for non-local clients without a custom transport;
the SDK does not guess a production hostname. Base paths are supported. HTTPS is required
except for HTTP loopback development endpoints. URLs with credentials, queries or fragments
are rejected. Redirects are never followed. Supply server credentials only from a trusted server.
Events and memory
Events support dialogue, choice, quest, relationship, inventory, achievement,
world and custom. importance is between 0 and 1 (default 0.5); source defaults to
sdk. Optional fields include sessionId, worldId, UTC timestamp, metadata and
idempotencyKey. An event with a session must belong to that session's character while active.
const batch = await fourBtd.events.captureBatch([
{
characterId: mira.id,
type: 'dialogue',
content: 'Welcome home.',
},
{
characterId: mira.id,
type: 'choice',
content: 'Helped the traveler.',
},
]);
for (const result of batch) {
if (result.success) {
console.log(result.data.id);
} else {
console.log(result.error.code);
}
}
const memory = await fourBtd.memory.commit({
characterId: mira.id,
type: 'semantic',
content: 'The river crossing is safe.',
tags: ['travel'],
});
await fourBtd.memory.evolve(
{
memoryId: memory.id,
content: 'The bridge is now damaged.',
salience: 0.95,
},
{
expectedVersion: memory.version,
},
);
const context = await fourBtd.memory.hydrate({
characterId: mira.id,
query: 'bridge',
tags: ['travel'],
types: ['semantic'],
minSalience: 0.5,
from: '2020-01-01T00:00:00Z',
to: '2100-01-01T00:00:00Z',
limit: 10,
});
await fourBtd.memory.forget(memory.id, 'Player requested removal from recall');Memory supports episodic, semantic, relationship, and world.
evolve applies an explicit content, salience, tags, or provenance patch. It increments the memory version while preserving its ID. It does not call a language model.
Event provenance must reference events belonging to the same character.
hydrate uses the same filters and ranking as recall:
All requested
tagsmust match.typesuses OR filtering.UTC creation times are filtered inclusively.
Query terms match case-insensitive substrings, with at least one term required to match.
Relevance is 70% matching-term fraction + 30% salience.
Without query terms, relevance is based entirely on salience.
Ties are resolved using the memory ID.
These are deterministic local heuristics, not embedding-based search.
Forgotten memories are excluded from recall and list, but retained for audit. Forgetting a memory does not physically erase it.
Relationships, inventory and achievements
await fourBtd.relationships.update({
characterId: mira.id,
subjectId: 'traveler',
trust: 0.8,
affinity: 0.6,
familiarity: 0.4,
rivalry: 0,
status: 'friend',
sharedEvents: [event.id],
});
const ally = await fourBtd.characters.create({
name: 'Taro',
owner: 'player-two',
});
await fourBtd.inventory.grant({
characterId: mira.id,
itemId: 'potion',
quantity: 2,
sourceWorld: 'forest',
});
await fourBtd.inventory.transfer({
characterId: mira.id,
toCharacterId: ally.id,
itemId: 'potion',
quantity: 1,
});
const ownership = await fourBtd.inventory.verify({
characterId: ally.id,
itemId: 'potion',
});
await fourBtd.achievements.unlock({
characterId: mira.id,
key: 'generous',
name: 'A Helping Hand',
});Relationship scores are clamped to [0, 1] when finite. Omitted scores preserve their existing values, or default to 0 when creating a new relationship.
Shared-event patches replace the existing list and must reference events belonging to the character.
Inventory behavior:
Quantities must be positive safe integers.
Transfers are atomic and require distinct, active owners.
Transfers preserve item provenance.
Removing the entire quantity returns
null.Existing stacks require matching optional asset addresses.
Grants preserve the existing stack's metadata and source world.
verifychecks recorded offchain ownership, not a Solana token account.
Achievement unlocks are intrinsically idempotent by characterId and key. After the first unlock, the original name, description, and metadata are retained.
Sessions and identity
await fourBtd.identity.create({
characterId: mira.id,
wallet: 'local-wallet',
});
const session = await fourBtd.sessions.start({
characterId: mira.id,
hydrate: true,
});
const scoped = fourBtd.withContext({
sessionId: session.id,
worldId: 'forest',
});
await scoped.events.capture({
characterId: mira.id,
type: 'quest',
content: 'Reached the lost city.',
importance: 1,
});
const ended = await scoped.sessions.end(session.id, {
checkpoint: true,
});
if (ended.checkpoint) {
const verification = await fourBtd.identity.verify({
checkpointId: ended.checkpoint.id,
});
}
const hydrated = await fourBtd.sync(mira, {
checkpoint: true,
});Run the local identity example using the local client from the quickstart.
start can optionally hydrate the session with character, memory, relationship, inventory, and achievement context.
end creates an extractive summary by joining events with importance >= 0.5 in capture order. Set summarize: false to omit the summary. Repeated calls to end return the already-ended session. Checkpointing requires an existing identity.
State Root
Each local mutation recomputes the character stateRoot using SHA-256 over canonical JSON containing:
Character, with its root set to
nullAll events
All memories, including forgotten entries
Relationships
Inventory
Achievements
Records are included in stored order.
The identity stateRoot represents the last checkpointed root. Sessions and identity records are not included in the state root.
The schema version starts at 4btd.character/v1.
Checkpoints
Checkpoints retain:
Wallet
Public key
Network
Portability
Transaction signature
Slot
Confirmation state
UTC timestamps
The local adapter automatically records checkpoints and verifies exact records against its own in-process registry. Keep the same adapter or transport across sessions.
Local verification provides offline integrity checking. It is not cryptographic proof of wallet ownership.
Solana verification and wallet delegation
import {
FourBTDClient,
InMemoryTransport,
SolanaIdentityAdapter,
type CheckpointSigner,
} from '@fourbtd/sdk';
// Your wallet integration implements this interface:
// construct, sign, and submit a transaction containing
// the exact supplied memo, then await finalization.
async function checkpointOnSolana(wallet: string, signer: CheckpointSigner) {
const adapter = new SolanaIdentityAdapter({
network: 'solana-devnet',
rpcUrl: 'https://api.devnet.solana.com',
signer,
});
const sdk = new FourBTDClient({
network: 'solana-devnet',
transport: new InMemoryTransport({
identityAdapter: adapter,
}),
});
const character = await sdk.characters.create({
name: 'Mira',
owner: 'player-one',
});
await sdk.identity.create({
characterId: character.id,
wallet,
});
const checkpoint = await sdk.identity.checkpoint({
characterId: character.id,
});
const published = await sdk.identity.publish({
checkpointId: checkpoint.id,
});
return sdk.identity.verify({
checkpointId: published.id,
});
}The signer exposes:
publish(
checkpoint,
memo,
signal?,
): Promise<{
signature: string;
slot: number;
}>;
Signer implementations must deduplicate submissions by checkpoint ID. The SDK does not hold private keys or construct wallet transactions.
Without a signer, the Solana adapter still validates keys and can verify supplied checkpoint records directly:
await adapter.verify(checkpoint);Solana Verification
Verification uses Solana's getTransaction RPC with:
jsonParsedencodingfinalizedcommitmentTransaction version
0support
Verification checks:
32-byte base58 public keys
64-byte base58 transaction signatures
Successful transaction metadata
Exact transaction slot and signature
Wallet signer participation
Exact
checkpointMemostored through the SPL Memo program
The SPL Memo program is:
MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr
The checkpoint memo binds the schema, character, checkpoint, network, wallet, public key, and state root.
The RPC URL must be trusted and point to the configured Solana cluster. Confirmation is never inferred from a caller-supplied signature alone.
create records a claimed identity. Successful publication and verification establish its wallet signature.
Portability is descriptive metadata, not an interoperability guarantee.
Requests, retries and observability
Every method accepts a final RequestOptions argument:
import { ConflictError, isFourBTDError } from '@fourbtd/sdk';
const controller = new AbortController();
try {
await fourBtd.characters.update(
mira.id,
{
description: 'Explorer',
},
{
expectedVersion: mira.version,
idempotencyKey: 'mira-profile-edit-1',
signal: controller.signal,
requestId: 'profile-request-1',
},
);
} catch (error) {
if (error instanceof ConflictError) {
const latest = await fourBtd.characters.get(mira.id);
// Reconcile your edit against the latest version
// before submitting a new operation.
} else if (isFourBTDError(error)) {
console.error(error.code);
} else {
throw error;
}
}
// Cancels an active request or its retry wait.
controller.abort();Retries and timeouts
Timeout is per attempt and defaults to 30 seconds.
Retries default to
2, for a maximum of 3 total attempts.Retry delay is calculated as:
min(maxBackoffMs, backoffMs * 2^attempt)
* (1 + jitter * (2 * random - 1))
The actual wait is always at least the server-provided Retry-After, expressed as seconds or an HTTP date.
Only reads or mutations with an idempotency key are retried. Retries apply to network errors, 408, 429, and 5xx responses.
Every SDK mutation receives one stable generated idempotency key across retries unless one is supplied explicitly. Persist your own key across process restarts or manually repeated calls when you need the same operation to remain idempotent.
Idempotency
Local idempotency is scoped by network, operation, and key. Reusing the same key with changed payload, context, or version produces a conflict.
Event-level idempotency keys are additionally scoped by character.
Batches support up to 100 inputs and return ordered successes or typed error data. A batch-level key replays the entire result, while event-level keys deduplicate individual events.
Do not reuse a context idempotency key for different mutations of the same operation.
Optimistic concurrency
expectedVersion checks the entity being mutated:
Character
Memory
Relationship
Inventory source stack
Identity
Session
For new relationship or inventory entries, use 0.
Creation of new immutable entities has no previous version to compare against.
sync checks the character version. Session-end checkpointing checks the session version, not a coincidentally matching identity version.
Data handling
Client calls detach inputs and outputs. Unknown external responses fail Zod validation.
Public dates use UTC ISO strings. Metadata must contain valid JSON values, and undefined optional properties are omitted.
Cancellation and timeout abort underlying work. Custom transports and signers must honor abort signals.
Cancellation cannot undo a transaction that has already been submitted to an external system.
Context
const scoped = fourBtd.withContext({
worldId: 'forest',
sessionId: session.id,
playerId: 'player-one',
trace: {
feature: 'quest',
},
idempotencyKey: 'quest-operation-1',
});withContext() returns a client that shares the same transport and dependencies.
Explicit method fields override context defaults. Setting a context override to undefined clears that default.
Context defaults apply as follows:
worldId→ characters, events, and sessionsplayerId→ sessionssessionId→ captured events, including batchesContext metadata → every transport request
Logging and hooks
Inject a logger with:
debug
info
warn
error
Lifecycle hooks include:
onRequestStart
onRequestSuccess
onRequestRetry
onRequestFailure
Hooks receive the operation, request ID, zero-based attempt, and optional error code or retry delay.
Logger output contains the operation, attempt, lifecycle phase, and redacted trace metadata. Request payloads are never logged.
Logging defaults to a no-op.
Use sensitiveFields to configure additional trace keys for redaction. Known API key values are also scrubbed.
For custom instrumentation:
redact(value, sensitiveFields);redact() sanitizes nested credentials and configured custom keys.
Instrumentation exceptions never alter requests.
Error details are automatically redacted. Arbitrary external causes and HTTP error bodies are discarded, while typed SDK causes may be retained through the standard cause property.
Pagination
let page = await fourBtd.events.list(mira.id, {
limit: 20,
});
const events = [...page.items];
while (page.nextCursor) {
page = await fourBtd.events.list(mira.id, {
limit: 20,
cursor: page.nextCursor,
});
events.push(...page.items);
}All list operations return:
{
items,
nextCursor: string | null,
}
limit accepts values from 1 to 100 and defaults to 20.
Cursors are opaque and bound to the operation and its filters. Changing filters invalidates an existing cursor.
The local store uses offset-based cursors rather than snapshot isolation. Concurrent insertions or removals can therefore change page membership while paginating.
hydrate returns a context bundle rather than a paginated result. Its limit controls the number of recalled memories, while other context collections are returned in full.
Complete public API reference
API Reference
Every method returns a Promise.
R— optional finalRequestOptionsP—{ limit?, cursor? }IDs accept strings as input and are branded on validated output.
The SDK exposes exact TypeScript contracts through:
Input<'module.method'>;
ParsedInput<'module.method'>;
Output<'module.method'>;operations exports the corresponding Zod input/output schemas and mutation flags.
TypeScript declarations ship in both .d.ts and .d.cts formats.
Module
Methods and results
characters
create(input, R) → Character``get(id, R) → Character``update(id, patch, R) → Character``list({ limit?, cursor?, status?, owner?, world? }, R) → Page<Character>``archive(id, R) → Character``hydrate(id, { limit? }, R) → Hydration
events
capture(input, R) → Event``captureBatch(inputs, R) → BatchResult<Event>[]``get(id, R) → Event``list(characterId, P, R) → Page<Event>
memory
commit(input, R) → Memory``recall(query, R) → Page<RecallResult>``list(characterId, P, R) → Page<Memory>``forget(id, reason?, R) → Memory``hydrate(query, R) → Page<RecallResult>``evolve({ memoryId, content?, salience?, tags?, provenance? }, R) → Memory
relationships
get(characterId, subjectId, R) → Relationship``update(input, R) → Relationship``list(characterId, P, R) → Page<Relationship>
inventory
grant(input, R) → InventoryItem``transfer({ characterId, toCharacterId, itemId, quantity }, R) → { from: InventoryItem | null, to: InventoryItem }``remove({ characterId, itemId, quantity }, R) → InventoryItem | null``verify({ characterId, itemId, quantity? }, R) → Verification``list(characterId, P, R) → Page<InventoryItem>
achievements
unlock({ characterId, key, name, description?, metadata? }, R) → Achievement``get(id, R) → Achievement``list(characterId, P, R) → Page<Achievement>
identity
resolve({ characterId }, R) → Identity``create({ characterId, wallet, publicKey?, portability? }, R) → Identity``checkpoint({ characterId }, R) → Checkpoint``publish({ checkpointId }, R) → Checkpoint``verify({ checkpointId }, R) → Verification``getHistory(characterId, P, R) → Page<Checkpoint>
sessions
start({ characterId, worldId?, playerId?, hydrate?, metadata? }, R) → Session``end(id, { summarize?, checkpoint? }, R) → Session``get(id, R) → Session
client
sync({ id }, { checkpoint?, ...R }) → Hydration``health(R) → { status: "ok", network, timestamp }``withContext(overrides) → FourBTDClient
Characters
Character creation requires name and owner.
Optional fields include:
description
traits
goals
world
metadata
metadata must contain valid JSON values.
Character updates accept a partial creation input.
Archived characters remain readable but reject new character-state mutations. archive is idempotent.
Character versions track profile updates and archival. Memory, relationship, inventory, session, and identity versions track changes to their respective entities.
Use state roots to detect aggregate character-state changes.
Public Exports
The SDK exports branded IDs for:
Character
Event
Memory
Session
Player
World
Item
Achievement
Checkpoint
It also exports entity, pagination, error, and configuration types, along with all runtime schemas.
Core interfaces include:
Transport;
Storage;
StoreState;
IdentityAdapter;
CheckpointSigner;
Clock;
IdGenerator;
Logger;Concrete adapters and transports are also exported.
Utilities include:
canonical;
stateRoot;
redact;
systemClock;
randomId;
noopLogger;
checkpointMemo;
isSolanaPublicKey;
validateUrl;Errors
Typed SDK errors include:
FourBTDError;
AuthenticationError;
AuthorizationError;
ValidationError;
NotFoundError;
ConflictError;
RateLimitError;
TimeoutError;
NetworkError;
ServerError;
AbortError;All SDK errors expose:
code
message
status?
requestId?
details?
retryAfterMs?
retryable
toJSON() returns safe error data.
Error utilities are also exported:
isFourBTDError;
isRetryableError;
errorForStatus;Transport, storage and adapter contracts
import { FourBTDClient, InMemoryTransport, type Transport } from '@fourbtd/sdk';
const delegate = new InMemoryTransport();
const transport: Transport = {
execute: (request) => delegate.execute(request),
};
const sdk = new FourBTDClient({
network: 'local',
transport,
});Custom Transport
Transport.execute(TransportRequest) → Promise<unknown> receives:
Operation
Validated input
Network
Detached context
Request ID
Idempotency key
Expected version
AbortSignal
Return raw data matching operations[operation].output, not the HTTP envelope.
Throw typed SDK errors for known failures.
An injected transport takes precedence over built-in transport selection. Configure its storage and identity adapter directly.
In-Memory Transport
new InMemoryTransport({
storage?,
clock?,
idGenerator?,
identityAdapter?,
});
InMemoryTransport uses MemoryStorage and LocalIdentityAdapter by default.
Storage.transaction(work) must:
Atomically serialize work against a
StoreStateRoll back on failure
Detach returned results
The built-in store keeps data and idempotency records in memory without eviction. Share the store to preserve state across multiple clients within the same process.
Durable storage is an application-level injection.
Use a separate transport and store for each network and tenant.
A signer performs an external side effect and must enforce checkpoint-ID idempotency independently of storage rollback.
Identity Adapter
IdentityAdapter.create(input, network)
→ { wallet, publicKey, confirmation }
IdentityAdapter.publish(checkpoint, signal?)
→ Checkpoint
IdentityAdapter.verify(checkpoint, signal?)
→ { valid, reason? }
The local transport owns identity storage, state hashing, and checkpoint history.
An identity adapter must not change checkpoint identity during publication.
HTTP backends own their identity implementation. Passing an identityAdapter cannot inject wallet functionality into a remote server.
Injectable Dependencies
Clock provides:
now(): Date
sleep(
ms: number,
signal?: AbortSignal,
): Promise<void>
IdGenerator(kind) must produce unique, non-empty IDs.
random supplies retry jitter.
These dependencies, along with logger and lifecycle hooks, can be injected through ClientOptions for deterministic and reproducible application behavior.
HTTP Backend Contract
Confirm the following contract before deployment.
Endpoints
Every operation uses POST:
/v1/{module}/{method}
Special client endpoints:
/v1/sync
/v1/health
Request body:
{
"input": {},
"context": {}
}Headers
Content-Type: application/json
X-Request-Id: <request-id>
X-Fourbtd-Network: <network>
Authorization: Bearer <api-key>
Idempotency-Key: <key>
If-Match: <version>
Authorization, Idempotency-Key, and If-Match are included when applicable.
If-Match uses an unquoted decimal version.
Success Responses
Successful responses use:
{
"data": "<operation output>"
}All successful responses are schema validated.
Unknown entity fields are stripped, and public timestamps use UTC ISO 8601.
Error Mapping
Status
SDK error
401
Authentication
403
Authorization
404
Not found
408
Timeout
409, 412
Conflict
429
Rate limit
5xx
Server
Other failures
Validation
HTTP error bodies are not reflected into SDK error messages.
Retry-After establishes the minimum retry delay.
Backend Requirements
Backend mutations must atomically deduplicate idempotency keys and honor optimistic versions.
The backend contract should also define:
Batch ordering and partial results
Pagination scope
Recall ranking
Retention behavior
Authentication
Tenant isolation
Do not connect the transport directly to an unrelated REST API without an adapter.
Browser & Security
Use the ESM entry with an ESM-aware bundler for tree shaking:
{
"sideEffects": false
}Direct browser usage requires bundling the bare Zod dependency or providing it through an import map.
Local browser usage requires no API key.
Production browser applications should communicate through your authenticated application backend. Never embed a server API key in client-side code.
Cross-origin HTTP servers must explicitly allow:
Your application origin
POSTSDK request headers
CommonJS consumers can use:
const { FourBTDClient } = require('@fourbtd/sdk');Read SECURITY.md for trust boundaries and privacy considerations.
An offchain assertion does not become true simply because its hash is recorded onchain. Inventory transfers also do not transfer blockchain assets.
Treat context and memory as untrusted application content when constructing AI prompts.
Development
npm ci
npm run format
npm run lint
npm run typecheck
npm test
npm run coverage
npm run build
npm run examples
npm run consumer
npm run verify
CI runs:
Formatting
Linting
Strict type checking
Tests
90% minimum line, statement, function, and branch coverage
Production build
Offline examples
Temporary ESM, CommonJS, and TypeScript consumer installation
Package inspection on Node.js 20, 22, and 24
consumer creates the package tarball, installs it in an isolated temporary project, verifies imports and declarations, checks packed paths, and cleans up the temporary project. Installation prefers cached dependencies but can access the npm registry when the cache is incomplete. The local examples and consumer runtime checks require no API key or backend access.
npm pack --dry-run
This rebuilds through prepack and displays the package allowlist.
Published packages contain only:
Built JavaScript
Type declarations
Source maps
README
License
Security policy
Changelog
Package metadata
Tests, development configuration, credentials, and development dependencies are not included.
Before release, replace owner metadata, license information, and security contact details. Confirm the backend contract and review provenance configuration.
This repository never publishes automatically.
Contributing
Contributions should include behavior-focused tests and update relevant examples and contracts alongside API changes.
Before submitting a pull request:
npm run verify
