ARCANUM
C++ client

Rune API

Connect, define a typed topic, produce, subscribe, run a transaction, query a view.

Connect

One session to any node; leader redirects and reconnects are handled for you.

#include <rune/client/Client.h> using namespace rune::client; ClientConfig cfg; cfg.gatewayChannel = "aeron:udp?endpoint=node1:40200"; // any node cfg.gatewayStreamId = 1; cfg.egressChannel = "aeron:udp?endpoint=me:40400"; // where replies arrive cfg.egressStreamId = 2; cfg.offerCrypto = true; // TLS 1.3 cfg.requireCrypto = true; cfg.tlsExpectHost = "node1.example"; Client client(cfg); client.connectAndWait();

from your game loop

cfg.runnerMode = RunnerMode::Invoker; // no client threads // ... while (running) { client.doWork(); // once per server tick tickWorld(); }

Topics

Defined in Rune QL from the rune console. All three are sharded by userId, so a player's data lives on one partition.

rune> topic user { userId int64, name string(32), level int32 } partitions 16 key userId; rune> topic character { userId int64, characterId int64, class string(16), level int32 } partitions 16 key (userId, characterId) partition by userId; rune> topic position { userId int64, characterId int64, zone int32, x float64, y float64, z float64 } partitions 16 key (userId, characterId) partition by userId; rune> alter topic character add column guildId int64, set compatibility backward;

transient topic: replicated in memory, never written to disk. For high-rate, short-lived state.

rune> transient topic presence { userId int64, online bool, zone int32 } partitions 16 key userId;

generate C++ types from the registry

rune schema generate character --out gen/ # gen/character.gen.h
or define a topic from code
auto d = rune::codec::DescriptorBuilder("user", 1) .add("userId", PrimType::Int64, 1, /*isKey=*/true) .add("level", PrimType::Int32) .build(); client.defineTopicAndWait("user", 16, "userId", /*validate=*/true, rune::codec::serialize(d));

Produce

Acks::None · Leader · All. trySend is the non-blocking form.

auto p = client.producer({.acks = Acks::All, .lingerUs = 200}); auto positions = client.topic("position"); p.send(positions, Key(userId, characterId), bytes); // keyed, idempotent

many records: sends within lingerUs are batched automatically

for (const auto& e : events) p.send(positions, Key(e.userId, e.characterId), e.bytes);

many records as one atomic append to one partition

auto b = p.batch(positions, Key(userId, characterId)); b.add(step1); b.add(step2); b.add(step3); b.send(); // contiguous offsets, all or nothing

zero-copy: write the record straight into the send buffer

auto c = p.tryClaim(positions, Key(userId, characterId), PositionEncoder::kEncodedLength); PositionEncoder(c.buffer()).userId(userId).characterId(characterId).zone(4).x(118.5).y(7.0).z(-42.25); c.commit(); // or c.abort()

zero-copy batch

auto b = p.claimBatch(positions, Key(userId, characterId), /*records=*/3, PositionEncoder::kEncodedLength); for (int i = 0; i < 3; ++i) PositionEncoder(b.record(i)).userId(userId).characterId(characterId).zone(4).x(path[i].x).y(path[i].y).z(path[i].z); b.commit(); // one atomic append

typed topic: generated flyweight, routed by the key

auto pos = client.openTopicAndWait<Position>("position"); auto w = pos.claim(userId, characterId); w.zone(4).x(118.5).y(7.0).z(-42.25); w.commit();

Subscribe

Push delivery, in order per partition. key and value are zero-copy spans, valid inside the handler.

auto sub = client.subscribe(positions, Key(userId, characterId), {.from = From::earliest(), .isolation = Isolation::ReadCommitted}, [](const Record& r) { apply(r.offset, r.key, r.value); });

Consumer group

Register before connectAndWait(). Offsets auto-commit; gc.commit() commits on demand.

GroupConfig g; g.autoOffsetReset = OffsetReset::Earliest; auto gc = client.subscribeGroup("matchmaking", {"match_events"}, g); gc.onAssigned([](std::vector<TopicPartition> parts) { /* ... */ }); gc.onRecords ([](const RecordSet& rs) { /* rs.partition, rs.recordBatch */ }); client.connectAndWait();

Transaction

Create before connectAndWait(). ReadCommitted readers see the trade only after commit.

#include <rune/client/producer/TransactionalProducer.h> auto tp = client.transactionalProducer("trade-worker-1"); client.connectAndWait(); tp.initTransactionsAndWait(); tp.beginTransaction(); tp.send(inventory, Key(int64_t{seller}), removeSword); tp.send(inventory, Key(int64_t{buyer}), addSword); tp.send(wallets, Key(int64_t{seller}), addGold); tp.commitTransactionAndWait(); // all three or none

Materialized views

Defined in Rune QL, kept current on every write, read by key. Persisted by default: checkpointed and recovered on restart, no rebuild. Add transient to keep one in memory only. No separate database.

one row per character: user, character and live position joined on userId and characterId

rune> create view character_view key (userId, characterId) as select u.name, c.class, c.level, p.zone, p.x, p.y, p.z from character c join user u on u.userId = c.userId left join position p on p.userId = c.userId and p.characterId = c.characterId;

per-user stats over all of a user's characters

rune> create view user_stats key userId as select count(*) as characters, max(level) as top_level, avg(level) as avg_level from character group by userId;

transient view: in memory only, rebuilt from its topics on restart

rune> create transient view user_presence key userId as select count(*) as characters_online, last(zone) as last_zone from position group by userId;

read

rune> get character_view (42, 7); name=Ayla class=ranger level=31 zone=4 x=118.5 y=7.0 z=-42.25
auto row = client.getViewRowAndWait(characterView, ViewKey(42, 7), fp); auto ch = CharacterViewDecoder(row.rowBytes); // generated from the view's schema ch.zone(); ch.x(); ch.level();

A position update rewrites only that character's row. A user rename updates every row of that user. Deleting a character retracts its row and its contribution to user_stats.