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:

TypeScriptC# counterpartWhat it does
RoomInputModelRoomInputModel<TInput>presses, canonical in-step order, fitsOneStep, tryMerge
InputBufferInputBuffer<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
InputSendQueueInputBuffer.OfferRange / LastAppliedSeqsequence numbers, redundant packets, acks, pending inputs
InputPacerInputBuffer.Depth / TargetDepthruns 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, quantizerScalar.Quantizewire-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) and TargetDepth (the queue it wants against measured arrival jitter). InputPacer aims for max(minDepth, TargetDepth, 1 + 2 * depth jitter), capped by maxDepth.
InputPacer optionDefaultMeaning
minDepth2Smallest queue kept on a steady link (inputs)
maxDepth5Largest queue aimed for (bounds input latency)
gain0.02Pace change per input of depth error
maxPace0.05Largest deviation of the pace from 1
smoothing0.1Smoothing 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