ludic/packages/ludic.telemetry
Orkuncakilkaya 3ac1179440 fix(allocs): ludic.update, steps, effects and telemetry at 0 frame allocs and 0 keeps
ludic.update: whether this copy can update itself is worked out once, when the updater is configured
(the panel asked every frame, building the path to the executable each time); the notes are a list
the state keeps, filled by update_notes_for when the version or the language changes, which the
game calls from its update tick. The feed's address is declared (once, when a check starts).
ludic.steps, ludic.effects, ludic.telemetry: what is left is made on an event and declared with its
bound - an arc's tables, a chapter's columns, a step's fact, an effect's start, end and clear, the
fact pool's growth, the player id, a props record's nesting stack - and two pushes into a caller's
kept list, at most a chapter's steps and the ring's size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:13:06 +03:00
..
tests ludic.telemetry: telemetry_str_open - a named string property the caller writes into the kept buffer (a look's numbers) 2026-09-28 15:22:26 +03:00
facts.ludic fix(allocs): ludic.update, steps, effects and telemetry at 0 frame allocs and 0 keeps 2026-09-28 20:13:06 +03:00
ident.ludic fix(allocs): ludic.update, steps, effects and telemetry at 0 frame allocs and 0 keeps 2026-09-28 20:13:06 +03:00
index.ludic feat(ludic.telemetry): objects and lists inside an event's properties, an item made of numbers, and a signature of what was written - all in the kept buffer 2026-09-28 15:17:20 +03:00
load.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
package.ludic feat(ludic.telemetry): an event queue batched to a PostHog-shaped endpoint 2026-09-25 05:33:16 +03:00
ports.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
props.ludic feat(ludic.telemetry): objects and lists inside an event's properties, an item made of numbers, and a signature of what was written - all in the kept buffer 2026-09-28 15:17:20 +03:00
props_nest.ludic fix(allocs): ludic.update, steps, effects and telemetry at 0 frame allocs and 0 keeps 2026-09-28 20:13:06 +03:00
queries.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
queue.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
README.md fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
ring.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
send.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
stamp.ludic fix(ludic.telemetry): nothing made per event - properties and the line written into kept buffers, the queue a ring of bytes made at the start 2026-09-28 15:15:12 +03:00
state.ludic capacities (25.5a): @max on Physics' bounded lists - every fact pool and free list (1024), the walker slots (64), the shape and body-owner tables (262144), npc's walkers and posts (64) 2026-09-28 16:49:58 +03:00

ludic.telemetry

What a game says about how it is played, sent to an analytics server without ever costing a frame. Each event is encoded once into a line held in memory; the lines go out in batches on Http's own thread; a failed send keeps its lines and waits twice as long next time; the queue is on disk every few seconds, so a crash or a quit loses little and the next start sends it; and a player who says no has nothing queued and the file deleted. Uses ludic.base and nothing else.

import "ludic.telemetry"

The wire format is PostHog's batch API: POST <host><path> with {"api_key": <key>, "batch": [{"event", "distinct_id", "properties", "timestamp"}, ...]}. Every event carries $session_id (a fresh one each start) and $lib. What an event is called and what else its properties say are the game's.

The player id is one per machine: the operating system's own id (Windows MachineGuid through PowerShell, macOS IOPlatformUUID through ioreg, Linux /etc/machine-id) hashed with the game's salt, so a reinstall comes back as the same player and the raw id never leaves the machine. With no salt, or where it cannot be read within ten seconds, it is random (r-...). It lives in the game's id file as {"id": ...}; any other key the game keeps there is left alone. An event queued before the id is known carries a mark that the batch replaces.

Nothing is made per event

Ludic gives nothing back, so an event is never a tree of values or a string of its own. Its properties are written as JSON into a kept buffer (telemetry_props), the line into another, and the line's bytes into one ring made at the start (ring_bytes, at most queue_max lines): past either, the oldest are overwritten. A batch and the queue file are written into a third kept buffer and go out as bytes (Http.body_bytes, Fs.write_bytes). An event's time is worked out from the clock's seconds, not formatted. tests/ring_test holds it: ten thousand events past the cap, 0 bytes.

Config and ports (bind)

property TelemetryConfig {
  host, key, path ("/batch/"), lib        # an empty host or key sends nothing, ever
  queue_file, id_file, id_salt, id_scratch
  flush_ms (30000), flush_at (50), batch (200), queue_max (5000), ring_bytes (1 MB), backoff_max (6), save_ms (5000)
}
port TelemetryWorld {
  can_send: fn() -> bool     # may this run send at all - a test, a headless run (unbound: yes)
  enabled: fn() -> bool      # the player's switch (unbound: on)
  now_ms: fn() -> int        # a clock (unbound: the wall clock, by the second)
  clock_s: fn() -> int       # the wall clock in seconds since 1970, for an event's time (unbound: now)
}
port TelemetryTransport {    # unbound: Http
  send: fn(string, []byte, int) -> int   # url, body, its length -> a handle, < 0 if it could not start
  poll: fn(int) -> int              # -1 pending, 0 failed, 1 taken
  drop: fn(int) -> void             # abandon one
}

The game binds what it needs once, where it is put together:

bind TelemetryWorld { can_send: fn my_can_send, enabled: fn my_switch }

API

telemetry_config(c) before the start
telemetry_start() once, at boot: the id, a session, and the queue an earlier start left (or none, if off)
telemetry_props(), telemetry_str(p, k, s), telemetry_int(p, k, n), telemetry_bool(p, k, b) the next event's properties, written into a buffer the state keeps
telemetry_event(name, props) queue one (props may be null); nothing is kept while off or while the run may not send
telemetry_tick() every frame: the machine id's child, a batch when due (every flush_ms, or at flush_at events), its answer, the backoff, the queue on disk every save_ms; off forgets
telemetry_save() the queue on disk now (a quit, a child about to start)
telemetry_forget() nothing queued, nothing on disk, the request in flight dropped
telemetry_set_id(id), telemetry_random_hex(n) an id outright; random hex that is distinct per start on Windows too (its Crypto.random_hex is zeros)
telemetry_on(), telemetry_get_config(), telemetry_id(), telemetry_session(), telemetry_ready(), telemetry_queued(), telemetry_line(i), telemetry_batch_body(n), telemetry_fails(), telemetry_next_ms(), telemetry_in_flight() what the game asks
telemetry_facts() -> Queue<TelemetryFact> { what, count, status, retry_ms }: TELEMETRY_SENT, TELEMETRY_FAILED, TELEMETRY_ID
telemetry_reset() back to before the start (tests)

It is not a System: it runs from the first frame of a launcher, before any world exists, so the game calls telemetry_tick() itself.

Tests

ludic build packages/ludic.telemetry/tests/telemetry_test.ludic --headless -o /tmp/telemetry_test && /tmp/telemetry_test

A fake clock and a fake transport: nothing reaches a network.