Files
skills/docs/gaming/games/game-server/src/plugins/ab-test-plugin/ab-test-plugin.md
2026-05-29 08:20:56 +01:00

3.7 KiB

ABTestPlugin

(no submodules)

Purpose

ABTestPlugin registers weighted experiments and assigns players to variants deterministically via an injected Rng. It records conversion events per (experiment, player) pair and exposes per-variant summary statistics so the game engine can drive data-informed balance decisions without taking a runtime dep on a separate experiments service.

The plugin is intentionally self-contained: it owns the experiment catalog, the assignment map, and the conversion log in memory. Persistence and statistical tests (z-test, chi-squared) are out of scope — callers compose the plugin with their preferred storage and analysis tooling.

Features

  • Weighted variant bucketing via Rng.nextFloat() (deterministic with a seeded RNG).
  • Idempotent assignment: re-calling assignPlayer returns the same variant.
  • Per-experiment conversion log with arbitrary goalId and numeric value.
  • Per-variant summary: assignments, conversions, total value, conversion rate.
  • Zod-validated experiment input (id, name, ≥1 variant with positive weight).
  • Lifecycle-safe: clears every experiment on onUninstall.

Usage: Basic

import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin";

const rng = { nextFloat: () => Math.random(), nextUint32: () => 0 };
const plugin = new ABTestPlugin({ rng });
plugin.createExperiment({
    id: "exp-1",
    name: "Drop rate test",
    variants: [
        { id: "control", weight: 1 },
        { id: "treatment", weight: 1 },
    ],
});
const a = plugin.assignPlayer("exp-1", "player-1");

Usage: Advanced

import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin";

// Seeded RNG for deterministic test runs.
let s = 1;
const rng = {
    nextUint32: () => (s = (s * 9301 + 49297) % 233280),
    nextFloat: () => {
        s = (s * 9301 + 49297) % 233280;
        return s / 233280;
    },
};

const plugin = new ABTestPlugin({ rng });
plugin.createExperiment({
    id: "drop",
    name: "Drop rate test",
    variants: [
        { id: "low", weight: 3 },
        { id: "high", weight: 1 },
    ],
});
plugin.assignPlayer("drop", "p1");
plugin.recordConversion("drop", "p1", "first-kill", 1);
const summary = plugin.getSummary("drop");

API Reference

  • class ABTestPlugin extends BasePlugin
  • constructor(options: ABTestPluginOptions)
  • createExperiment(input: ExperimentInput): void
  • hasExperiment(id: string): boolean
  • assignPlayer(experimentId: string, playerId: string): Assignment
  • getAssignment(experimentId: string, playerId: string): Assignment | undefined
  • recordConversion(experimentId: string, playerId: string, goalId: string, value?: number): Conversion
  • getSummary(experimentId: string): readonly VariantSummary[]
  • listExperiments(): readonly string[]

Implementation Notes

  • Bucketing accumulates variant weights and compares against nextFloat() * totalWeight. The implementation falls back to the last variant if the accumulator never exceeds the threshold (a defensive guard against floating-point ulp drift when weights are tiny).
  • Recording a conversion for an unassigned player throws GAME_SERVER.AB_TEST_INVALID_VARIANT so callers can route the error to their analytics dead-letter queue rather than corrupting the report.
  • The summary recomputes from raw records on each call. The data sets are small (tens of variants, thousands of players); pre-aggregation would add latency on a rare path. Callers needing high-frequency dashboards should snapshot the summary at a checkpoint instead of polling.
  • The plugin uses BasePlugin.assertLive on every public method so a destroyed plugin cannot accidentally serve stale data.