Event-driven Runtime

- Unreliable timing: The timing of the crank is not guaranteed to be consistent. For example, the crank might miss a block because of latency or congestion causing the game state to enter an invalid state.
- Increases system complexity & decreases resiliency: The crank introduces another moving part to your game system that can cause headaches when it goes down or when it’s not working as expected.
- Cost: The cost of running a crank is high as it requires a transaction to be sent to the smart contract at every fixed interval. This can be incredibly expensive if you are running a crank every second.
Loop-driven Runtime

- Support for broader game mechanics: Event-driven runtimes limit the types of game mechanics that can be implemented. For example, it is not possible to reliably implement a gravity mechanic that updates the player’s location every subsecond. With a loop-driven runtime, this can be trivially accomplished.
- Consistent ordering of game actions: In an event-driven runtime, each game action is executed in a different transaction. This means that the ordering of game actions is not guaranteed. For example, if a player is hit by a bullet and the player’s HP is reduced to 0, the player might still be able to shoot a bullet before the player’s HP is reduced to 0. This is because the player’s HP is reduced in a different transaction than the player’s shooting action. This is not an issue with loop-driven runtimes because all game actions are executed in an explicit sequential order.
- Zero cost: Loop-driven runtimes do not require any transactions to be sent to the smart contract. This means there is no cost associated with updating the game state. Using this architecture, game state can be updated as frequently as you want while eliminating extra gas costs.
Tracing the Loop
Cardinal exports OpenTelemetry traces over OTLP gRPC. The defaults target groundcover:OTEL_EXPORTER_OTLP_ENDPOINT defaults to the in-cluster sensor at groundcover-sensor.groundcover.svc.cluster.local:4317, which is reached in plaintext. To send directly to groundcover’s BYOC ingestion endpoint from outside the cluster, set the endpoint to https://{BYOC_ENDPOINT} (the https:// scheme turns on TLS) and pass the ingestion key through OTEL_EXPORTER_OTLP_HEADERS=apikey={ingestion-key}. Set OTEL_TRACE_SAMPLE_RATE between 0.0 and 1.0 to control how many ticks are sampled. Setting OTEL_EXPORTER_OTLP_ENDPOINT to an empty string disables tracing.
Each tick is one root trace named cardinal.tick with these children:
Startup emits
cardinal.init (init systems, also on debug reset) and cardinal.restore (snapshot load).
Game spans and logs. Inside a system, world.Context() is the trace context of that system’s span. Start your own spans from it to nest them in the tick’s trace, and pass it to instrumented clients so their spans join too. Log lines from world.Logger() carry the same span’s trace_id and span_id.
SendCommand request span (from ConnectRPC) and, for a command from another shard, the NATS handler.isc.* span carry cardinal.command.name. The request finishes before the tick that processes the command, so the tick is not its child. Instead, cardinal.tick holds one span link per drained command whose request span was sampled (capped at 128, the SDK’s default link limit), tagged with cardinal.command.name and cardinal.command.sender (the player ID, or the sending shard’s address). Follow the link from the tick to find the request, or search the request’s trace for linked ticks.
Inter-shard commands. cardinal.command.send injects the tick’s trace context into the NATS request headers, so the receiving shard’s handler span joins the sending tick’s trace. The receiving tick is a new root trace of its own and links back to that handler span, the same way a local SendCommand is linked. A cross-shard hop is therefore two traces joined by a link, not one continuous trace.
Metrics
Cardinal exports OpenTelemetry metrics over OTLP gRPC whenOTEL_EXPORTER_OTLP_METRICS_ENDPOINT is set, for example to groundcover-sensor.groundcover.svc.cluster.local:4317. Unlike traces, metrics never fall back to OTEL_EXPORTER_OTLP_ENDPOINT, because the cardinal operator points that variable at a collector that accepts traces only. The endpoint takes the same forms as the trace endpoint, and OTEL_EXPORTER_OTLP_METRICS_INSECURE overrides OTEL_EXPORTER_OTLP_INSECURE. Metrics are pushed every OTEL_METRIC_EXPORT_INTERVAL milliseconds (default 60000) and are not sampled.
Game metrics. Create instruments from
otel.Meter("mygame") after NewWorld, which installs the meter provider, and record them from systems. Without a metrics endpoint they are no-ops.