CultLib/docs/start
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Libby 00345b424b
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/start/README.md
2026-11-07 12:09:33 +00:00
..
io-game docs/start: two-client .io game recipe over CultCache + CultMesh (TS) 2026-11-06 12:20:06 +00:00
io-game.md docs/start: two-client .io game recipe over CultCache + CultMesh (TS) 2026-11-06 12:20:06 +00:00
README.md Merge remote-tracking branch 'origin/main' into HEAD 2026-11-07 12:09:33 +00:00

Start Here

CultLib is one stack in several languages: CultCache (typed .cc persistence), CultNet (schema-v0 wire messages and transports) on top of it, and CultMesh (Verse discovery, replication, authority) on top of that. CultMath and Geometry sit beside the stack as portable numerics and engine-neutral geometry. The C# runtime in src/ is the reference; the other runtimes keep wire parity with it.

If you want typed state on disk, start with CultCache. Everything else assumes it.

Language x package

A cell links a snippet that a test in this repo runs, or says not yet. "Package" links the package's own README or source, which is real but carries no tested first-call snippet from this page.

CultCache CultMath Geometry CultNet CultMesh
C# first call, tested package; snippet not yet package; snippet not yet first call, tested; package quickstart; run by scripts/verify-eve-getting-started.ps1
TypeScript first call, tested not yet not yet first call, tested; package package; snippet not yet; recipe: two-client .io game, tested with two live clients, browser page not yet run
Rust first call, tested native core; snippet not yet not yet first call, tested; package, examples package; snippet not yet
Python first call, tested not yet not yet first call, tested; package package; snippet not yet

For the whole picture of what each piece is for, see the package chooser in the top-level README.

C# first call

Define a typed document, put it in a cache backed by a .cc file, read it back through a second cache. The source is tests/GameCult.Caching.Tests/FirstCallTests.cs, which is what dotnet test tests/GameCult.Caching.Tests --filter FirstCallTests runs, so the snippet cannot drift without the test failing.

using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;

[CultDocument("gamecult.first_call_item", "gamecult.first_call_item.v1")]
[MessagePackObject]
public sealed class Item
{
    [Key(0)] [CultName] public string Name = string.Empty;
    [Key(1)] [CultIndex] public string Category = string.Empty;
    [Key(2)] public int Value;
}

public static class FirstCall
{
    public static async Task<Item?> Run(string path)
    {
        var store = new SingleFileMessagePackBackingStore(path);
        var cache = new CultCache();
        cache.AddBackingStore(store);
        var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 });
        store.PushAll();                                   // write the .cc file

        var reopened = new CultCache();
        reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
        await reopened.PullAllBackingStoresAsync();        // read it back
        var potion = reopened.Get<Item>(handle.Key);       // by key
        var byName = reopened.GetByName<Item>("Potion");   // by [CultName]
        return byName ?? potion;
    }
}

FirstCall.Run is the call; a second test in that file fails if the block above stops matching it. More: src/GameCult.Caching/README.md.

TypeScript first call

Same shape in TypeScript: the document is a name, a schema id and a parse-able schema (zod here), and the .cc file is wire-compatible with the C# one. The source is packages/cultcache-ts/test/first-call.test.ts, run by npm test in packages/cultcache-ts (or node --test dist-test/test/first-call.test.js after tsc).

import { z } from "zod";
import {
  CultCache,
  SingleFileMessagePackBackingStore,
  defineDocumentRegistry,
  defineDocumentType,
} from "@gamecult/cultcache-ts";

const itemDocument = defineDocumentType({
  type: "item",
  schemaId: "gamecult.first_call_item.v1",
  schemaName: "gamecult.first_call_item",
  schemaVersion: "gamecult.first_call_item.v1",
  schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }),
  name: "name",
  indexes: { category: "category" },
});
const registry = defineDocumentRegistry(itemDocument);

async function firstCall(path: string) {
  const cache = CultCache.builder()
    .withRegistry(registry)
    .withGenericStore(new SingleFileMessagePackBackingStore(path))
    .build();
  await cache.pullAllBackingStores();
  await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 });

  const reopened = CultCache.builder()
    .withRegistry(registry)
    .withGenericStore(new SingleFileMessagePackBackingStore(path))
    .build();
  await reopened.pullAllBackingStores();
  const potion = reopened.get(itemDocument, "item:potion");
  const byName = reopened.getByName(itemDocument, "Potion");
  return { potion, byName };
}

The test imports from ../src/index where the block reads @gamecult/cultcache-ts; a second test in that file fails if the block stops matching the rest. More: packages/cultcache-ts/README.md.

Rust first call

Same shape with derive(DatabaseEntry): slots are #[cultcache(key = N)], so the .cc payload is the slot-indexed array the C# runtime writes. The source is packages/cultcache-rs/tests/first_call.rs, run by cargo test --test first_call in packages/cultcache-rs; a second test in that file fails if the block below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More: packages/cultcache-rs/README.md.

#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "item")]
struct Item {
    #[cultcache(key = 0)]
    name: String,
    #[cultcache(key = 1)]
    category: String,
    #[cultcache(key = 2, default)]
    value: u32,
}

fn first_call() -> anyhow::Result<()> {
    let dir = tempfile::tempdir()?;
    let path = dir.path().join("first-call.cc");

    let mut cache = CultCache::new();
    cache.register_entry_type::<Item>()?;
    cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
    cache.pull_all_backing_stores()?;
    cache.put("item:potion", &Item {
        name: "Potion".to_string(),
        category: "Consumable".to_string(),
        value: 50,
    })?; // written to the .cc file before this returns

    let mut reopened = CultCache::new();
    reopened.register_entry_type::<Item>()?;
    reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
    reopened.pull_all_backing_stores()?; // read it back
    let potion = reopened.get_required::<Item>("item:potion")?;
    assert_eq!(potion.name, "Potion");
    assert_eq!(potion.value, 50);
    Ok(())
}

Python first call

A document is a definition with an encoder and decoder; name= and indexes= register the name lookup and indexes. The source is packages/cultcache-py/tests/test_first_call.py, run by python -m unittest tests.test_first_call in packages/cultcache-py with src on PYTHONPATH and msgpack installed (the store needs it). A second test fails if the block below stops matching the file. More: packages/cultcache-py/README.md.

import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path

from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_document_type


@dataclass
class Item:
    name: str
    category: str
    value: int


item_doc = define_document_type(
    "item",
    encode=lambda item: asdict(item),
    decode=lambda raw: Item(**raw),
    name="name",
    indexes={"category": "category"},
)


def first_call() -> Item:
    path = Path(tempfile.mkdtemp()) / "first-call.cc"

    cache = (
        CultCache.builder()
        .register_document_type(item_doc)
        .add_generic_store(SingleFileMessagePackBackingStore(path))
        .build()
    )
    cache.pull_all_backing_stores()
    cache.put(item_doc, "item:potion", Item("Potion", "Consumable", 50))  # written to the .cc file

    reopened = (
        CultCache.builder()
        .register_document_type(item_doc)
        .add_generic_store(SingleFileMessagePackBackingStore(path))
        .build()
    )
    reopened.pull_all_backing_stores()  # read it back
    assert reopened.get_by_name(item_doc, "Potion") == reopened.get_required(item_doc, "item:potion")
    return reopened.get_required(item_doc, "item:potion")

CultNet first calls

CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same shape: define a typed document, build a cultnet.document_put_raw.v0 message carrying its MessagePack payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each block is verbatim from the test that runs it; a second test in the same file fails if the block stops matching.

C# CultNet first call

Source: tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs, run by dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests. More: src/GameCult.Networking/README.md.

using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;

[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
[MessagePackObject]
public sealed class Note
{
    [Key(0)] [CultName] public string Title = string.Empty;
    [Key(1)] public string Body = string.Empty;
}

public static class CultNetFirstCall
{
    public static async Task<Note?> Run()
    {
        var inbox = new CultCache();                            // the receiver's cache
        var documents = new CultNetDocumentRegistry();
        var received = new TaskCompletionSource<bool>();

        // Receive: a loopback listener that applies each raw document put to the inbox.
        using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
        server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
        {
            await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
            received.TrySetResult(true);
        });

        // Send: a typed document as a cultnet.document_put_raw.v0 message.
        using var client = new TcpFramedCultNetSchemaClient();
        client.Connect("127.0.0.1", server.LocalEndPoint.Port);
        var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
        client.SendCultNet(documents.CreateRawDocumentPutMessage(
            "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));

        await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
        return inbox.Get<Note>(handle.Key);
    }
}

TypeScript CultNet first call

Source: packages/cultnet-ts/test/first-call.test.ts, run by npm test in packages/cultnet-ts (build packages/cultcache-ts first: npm run build there). The test imports from ../src where the block reads cultnet-ts. More: packages/cultnet-ts/README.md.

import { createServer, connect } from "node:net";
import { z } from "zod";
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
import {
  CultNetDocumentRegistry,
  createTcpFramedCultNetPeer,
  defineCultNetDocumentBinding,
} from "cultnet-ts";

const noteDocument = defineDocumentType({
  type: "note",
  schemaId: "gamecult.first_call_note.v1",
  schemaName: "gamecult.first_call_note",
  schemaVersion: "gamecult.first_call_note.v1",
  schema: z.object({ title: z.string(), body: z.string() }),
  name: "title",
});
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);

async function firstCall() {
  const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
  const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache

  // Receiver: a loopback listener whose peer applies each document put to the inbox.
  const received = new Promise<void>((resolve, reject) => {
    const server = createServer((socket) => {
      const peer = createTcpFramedCultNetPeer(socket, options);
      peer.on("invalidMessage", reject);
      peer.on("message", async (message) => {
        if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
        await documents.applyRawDocumentPutMessage(inbox, message);
        peer.close();
        server.close();
        resolve();
      });
    }).listen(0, "127.0.0.1", () => {
      // Sender: connect, build a typed put message, send it.
      const { port } = server.address() as { port: number };
      const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
      sender.send(
        documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
          title: "Hello",
          body: "sent over CultNet",
        }),
      );
      received.finally(() => sender.close());
    });
  });
  await received;
  return inbox.get(noteDocument, "note:hello");
}

Rust CultNet first call

Source: packages/cultnet-rs/tests/first_call.rs, run by cargo test --test first_call in packages/cultnet-rs. More: packages/cultnet-rs/README.md.

use std::net::{TcpListener, TcpStream};
use std::thread;

use cultnet_rs::{
    CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
    TcpFramedTransportConnection, create_tcp_framed_transport_profile,
    decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};

#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "note")]
struct Note {
    #[cultcache(key = 0)]
    title: String,
    #[cultcache(key = 1)]
    body: String,
}

fn first_call() -> anyhow::Result<Note> {
    let mut documents = CultNetDocumentRegistry::new();
    documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
        "gamecult.first_call_note.v1".to_string(),
        "gamecult.first_call_note.v1".to_string(),
    ))?;
    let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());

    // Receiver: a loopback listener that applies the document put it receives to its own cache.
    let listener = TcpListener::bind("127.0.0.1:0")?;
    let port = listener.local_addr()?.port();
    let receiver = thread::spawn({
        let (documents, profile) = (documents.clone(), profile("receiver"));
        move || -> anyhow::Result<Note> {
            let (stream, _) = listener.accept()?;
            let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
            let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
            let mut inbox = CultCache::new();
            inbox.register_entry_type::<Note>()?;
            documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
        }
    });

    // Sender: build a typed put message, encode it for the wire, send one frame.
    let put = documents.create_raw_document_put_message(
        "put-1",
        "note:hello",
        &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
        CultNetDocumentPutOptions::default(),
    )?;
    let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
    TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
        .send("schema", &bytes)?;

    receiver.join().expect("receiver thread")
}

Python CultNet first call

Source: packages/cultnet-py/tests/test_first_call.py, run by python -m unittest tests.test_first_call in packages/cultnet-py with both src and ../cultcache-py/src on PYTHONPATH and msgpack installed. The socket pair stands in for a TCP connection. More: packages/cultnet-py/README.md.

import socket
from dataclasses import asdict, dataclass

from cultcache_py import CultCache, define_document_type
from cultnet_py import (
    apply_raw_document_record,
    document_put_raw,
    encode_frame,
    parse_message,
    read_frame,
    schema_document_map,
)


@dataclass
class Note:
    title: str
    body: str


note_doc = define_document_type(
    "note",
    encode=lambda note: asdict(note),
    decode=lambda raw: Note(**raw),
    name="title",
)


def first_call() -> Note:
    sender, receiver = socket.socketpair()  # an in-process pipe; use socket.create_connection for TCP

    # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
    put = document_put_raw(
        message_id="put-1",
        key="note:hello",
        schema_id=note_doc.catalog_entry().schema_id,
        stored_at="2026-01-01T00:00:00Z",
        payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
    )
    sender.sendall(encode_frame(put.to_bytes()))

    # Receive: read the frame, parse the message, apply its record to a second cache.
    message = parse_message(read_frame(receiver.makefile("rb")))
    assert message.schema_version == "cultnet.document_put_raw.v0"
    inbox = CultCache.builder().register_document_type(note_doc).build()
    apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
    sender.close()
    receiver.close()
    return inbox.get_required(note_doc, "note:hello")

Where next

  • Smallest durable mesh path: src/GameCult.Mesh/docs/durable-node-quickstart.md.
  • Application path, four chapters: src/GameCult.Mesh/docs/getting-started/README.md.