Client Input (TypeScript)
A match room applies one input per player per step from an InputBuffer<TInput>, and declares what an input is with a RoomInputModel<TInput> (which buttons act on their press, in which order inside a step). The client has to produce exactly the stream that buffer expects: numbered inputs, one per step, every press kept and in canonical order, paced so the server's queue never runs dry.
@altruist/input is that client side, with the same model as the server:
| TypeScript | C# counterpart | What it does |
|---|---|---|
RoomInputModel | RoomInputModel<TInput> | presses, canonical in-step order, fitsOneStep, tryMerge |
InputBuffer | InputBuffer<TInput> | the server's buffer, for offline sessions, link simulators and tests |
InputSequencer | (client only) | device changes in, one canonical input per step out |
StateMerger | (client only) | several devices of one player in one change stream |
PauseDetector, FrameCounter | (client only) | drop taps made while a menu was open |
InputSendQueue | InputBuffer.OfferRange / LastAppliedSeq | sequence numbers, redundant packets, acks, pending inputs |
InputPacer | InputBuffer.Depth / TargetDepth | runs the input clock faster or slower to keep the server's queue at its target |
ButtonField | (shifts) | multi-bit values packed in the button word (a direction index) |
quantize, quantizer | Scalar.Quantize | wire-exact axes, so prediction steps the server's input |
The package lives at Clients/TypeScript/input. It is dependency-free ESM TypeScript like @altruist/sim2d (same path-alias setup), and imports Scalar.quantize from the sibling sim2d sources.
Declaring the Model
Declare the same table as the server's InputModel. The builder has the same shape:
// Server (IRoomGame.InputModel)
public RoomInputModel<Pad> InputModel { get; } = RoomInputModel<Pad>.Describe()
.Buttons(p => p.Buttons, (p, b) => p with { Buttons = b })
.Action(Jump, stage: 0)
.Action(Fire, stage: 1)
.Build();
// Client
import { RoomInputModel } from '@altruist/input';
type Pad = { x: number; y: number; buttons: number };
export const padModel = RoomInputModel.describe<Pad>()
.buttons((p) => p.buttons, (p, b) => ({ ...p, buttons: b }))
.action(JUMP, 0)
.action(FIRE, 1)
.build();
Equality defaults to field-wise comparison of plain objects (like a C# record struct); pass .equals(fn) for nested inputs. Button masks are full 32-bit ints (bit 31 included). Bits that are not declared as actions are held state: a held brake, a flag, or a number packed into the word with ButtonField:
const AIM_DIR = new ButtonField(6, 3); // bits 6..8: an 8-way direction index
const buttons = AIM_DIR.write(DASH, 5);
AIM_DIR.read(buttons); // 5
Never declare an action on a field's bits: the model would see a press when the value changes.
presses(prev, cur), appendInOrder / inOrder, fitsOneStep, isCanonical, starved and tryMerge return what the C# methods return for the same declaration. tryMerge returns the merged input or undefined (the C# out form).
Sequencing Device Input
InputSequencer turns timestamped device changes into one input per step without losing or reordering a press:
- a tap shorter than a step still shows for exactly one step;
- a step takes presses in stage order, one per stage; a press that would break the order waits for the next step;
- after a press nothing else changes in that step (a stick flicked 2 ms after a jump belongs to the next step, so the jump stays a plain jump);
- a button released and pressed again within a step is released for one step, then pressed.
import { FrameCounter, InputSequencer, PauseDetector, StateMerger, combineLargestAxes } from '@altruist/input';
const seq = new InputSequencer(padModel, NEUTRAL);
const frames = new FrameCounter();
const pause = new PauseDetector(frames); // 8 frames without sampling = a menu was open
const merger = new StateMerger(combineLargestAxes<Pad>(['x', 'y'], 'buttons', NEUTRAL), (a, b) => padModel.equals(a, b), NEUTRAL);
function onRenderFrame(now: number) {
frames.mark();
for (const c of merger.merge([keyboard.drain(), gamepad.drain()], now)) seq.push(c);
}
function sampleForStep(now: number, until?: number): Pad {
if (pause.resumed()) seq.resync();
return seq.next(now, until);
}
until is the wall time at which a step's slice ends: after a hitch, a frame runs several steps and each takes only the input made during its own slice. runFixedSteps in @altruist/prediction computes it (stepCutoff).
Times are wall milliseconds (performance.now()); the sequencer has no clock of its own, so tests drive it with explicit times.
Sending and Pacing
import { InputPacer, InputSendQueue } from '@altruist/input';
const queue = new InputSendQueue<Pad>({ redundancy: 3 }); // each packet repeats the last 3 inputs
const pacer = new InputPacer();
// Every step:
queue.push(input);
send(queue.packet()); // { lastSeq, inputs } -> InputBuffer.OfferRange(lastSeq, inputs) on the server
// Every snapshot:
queue.ack(snap.ack); // InputBuffer.LastAppliedSeq
pacer.observe(snap.inputDepth, snap.target); // InputBuffer.Depth and TargetDepth
// Run the fixed-step clock at pacer.pace (0.95 .. 1.05).
The contract with InputBuffer:
- sequences start at 1 and grow by one per step; after the server resets the buffer (rejoin, new match) call
queue.reset(); - the ack can count a speculative step (the server repeated our held state on a starved step and stood it in for our next sequence). That input then counts as applied and leaves the pending list;
- the server reports
Depth(our inputs still queued) andTargetDepth(the queue it wants against measured arrival jitter).InputPaceraims formax(minDepth, TargetDepth, 1 + 2 * depth jitter), capped bymaxDepth.
InputPacer option | Default | Meaning |
|---|---|---|
minDepth | 2 | Smallest queue kept on a steady link (inputs) |
maxDepth | 5 | Largest queue aimed for (bounds input latency) |
gain | 0.02 | Pace change per input of depth error |
maxPace | 0.05 | Largest deviation of the pace from 1 |
smoothing | 0.1 | Smoothing factor per report for depth and depth jitter |
Running the Server's Buffer Locally
InputBuffer is a line-by-line twin of the C# class: same options and defaults (maxQueued 16, maxTarget 2, drainWindow 30, maxSpeculation 8, ...), same acks, depths, target depths, merges, speculation and statistics. Use it for offline or host-side play that should behave like an online match, and for testing pacing against a simulated link.
Golden Vectors
The C# test ClientTwinGoldenVectorTests runs seeded scenarios through the real RoomInputModel and InputBuffer (presses, orders, merges and starved inputs for five models including 32-bit masks and a packed value field; ten link scenarios with jitter, stalls, loss, reordering, duplicates, overflow and resets) and writes the results to Clients/TypeScript/input/tests/golden/*.json. The TypeScript tests replay them and must match every value. The C# test fails when a committed file is stale; regenerate with:
ALTRUIST_WRITE_GOLDEN=1 dotnet test --filter ClientTwinGoldenVectorTests
cd Clients/TypeScript/input && npm test