@devjmd/rocket-league-stats-api
v0.1.2
Published
Fully typed, zero dependency client for the official Rocket League Stats API. Reads live match ticks and events over a local TCP socket and sends spectator or replay commands back to the game.
Maintainers
Readme
@devjmd/rocket-league-stats-api
Typed client for the Rocket League Stats API. Reads live match data over the game's local socket and sends spectator and replay commands back.
ESM only. Zero dependencies. All 22 events and all 6 commands, validated at runtime.
npm install @devjmd/rocket-league-stats-api
pnpm add @devjmd/rocket-league-stats-api
bun add @devjmd/rocket-league-stats-api
yarn add @devjmd/rocket-league-stats-apiNode 24+. CI installs the packed tarball with all four managers and runs it, so compatibility is tested rather than assumed. The Bun runtime works too, not just its installer.
Enable the game
Edit <Install Dir>\TAGame\Config\TAStatsAPI.ini, then restart Rocket League.
[TAGame.MatchStatsExporter_TA]
PacketSendRate=30
Port=49123
WebPort=49124PacketSendRate=0 disables the API, so it doubles as the on switch. Events fire only during a live match.
Use
import { RocketLeagueStatsClient } from '@devjmd/rocket-league-stats-api';
const client = new RocketLeagueStatsClient()
.tickRate(10)
.on('UpdateState', (tick) => render(tick.Game, tick.Players))
.on('GoalScored', (goal) => console.log(goal.Scorer.Name))
.onError((error) => console.error(error.code, error.message));
await client.connect();Plugins
import { on, throttle, StatsPlugin } from '@devjmd/rocket-league-stats-api';
class Scoreboard extends StatsPlugin {
@on('UpdateState')
@throttle(100)
render(tick: UpdateStateData): void {
draw(tick.Game);
}
}
const client = new RocketLeagueStatsClient().use(new Scoreboard());@on and @once subscribe, @throttle(ms) rate limits, @bound binds the method. A handler declared for the wrong payload is a compile error. client.unuse(plugin) removes everything it registered.
Tick rate
client.tickInterval(100); // at most one tick per 100ms
client.tickRate(10); // same, as updates per second
client.tickInterval(0); // every tick the game sendsTicks coalesce rather than queue, so you always get the newest. Discrete events are never throttled. client.snapshot holds the latest tick if you prefer to poll.
Streaming
for await (const message of client) {
if (message.event === 'GoalScored') console.log(message.data.Scorer.Name);
}Commands
client.watchPlayer(3, 'PlayerView');
client.watchBall('SoftAttach');
client.setHudVisibility(false);
client.setGameSpeed(0.5);
client.setMatchPaused(true);
client.loadReplay({ FileName: 'Stadium_P_2026-06-05_18-42' });
client.seekReplay({ TimeSeconds: 120.5 });Invalid commands throw locally, because the game rejects them silently.
Events
UpdateState BallHit BoostPickup ClockUpdatedSeconds CountdownBegin CrossbarHit GoalReplayStart GoalReplayWillEnd GoalReplayEnd GoalScored MatchCreated MatchInitialized MatchEnded MatchDestroyed MatchPaused MatchUnpaused PlayerJoined PlayerLeft PodiumStart ReplayCreated RoundStarted StatfeedEvent
Lifecycle events are camelCase so they cannot collide: connected disconnected error message unknownEvent warning.
Use the named constants instead of raw strings if you prefer:
import { StatsEvent, LifecycleEvent, StatsCommand } from '@devjmd/rocket-league-stats-api';
client.on(StatsEvent.GoalScored, (goal) => console.log(goal.Scorer.Name));
client.on(LifecycleEvent.Disconnected, (info) => console.log(info.reason));
client.send({ Command: StatsCommand.SetGameSpeed, Data: { Speed: 0.5 } });
class Overlay extends StatsPlugin {
@on(StatsEvent.UpdateState)
render(tick: UpdateStateData): void {}
}Your own enum works too, since the parameter is a string literal union:
enum MyEvents {
Goal = 'GoalScored',
}
client.on(MyEvents.Goal, (goal) => console.log(goal.Scorer.Name));The exported constants are plain frozen objects rather than enums because enum is the one piece of TypeScript that cannot be erased. node file.ts fails on it with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, and it emits a runtime object either way. Constants keep exact literal types, so narrowing is identical and raw strings stay interchangeable.
Validation
A payload that fails validation is not delivered, and error reports the exact path that failed. Unexpected but harmless values report on warning and are still delivered. Unmodelled events arrive on unknownEvent. Undocumented fields stay readable on message.raw.
Gotchas
Properties of the API itself, not of this library.
MatchGuidis only set for online and LAN matches, so it is optional everywhere.StatfeedEvent.Typeis localized text. Branch onEventName.GoalScored.GoalTimeis the previous round's length, not the match clock.Ball.TeamNumis255until the ball is touched.Speed,Boostand theb-prefixed flags onPlayerare spectator only.FrameandElapsedare replay only.PrimaryIdis not unique, since bots share a placeholder. UseparsePrimaryId.- No continuous positions, so a live minimap is not possible from this feed.
- On an own goal,
Scoreris the benefiting team andBallLastTouchis who scored it.
Errors
Every error carries a code: connection_failed, connect_timeout, not_connected, invalid_command, invalid_payload, malformed_frame.
connect() rejects on the first failure. Reconnect with backoff applies only after an established connection drops.
Other transports
The game can also open a WebSocket on WebPort. Feed it straight in:
socket.addEventListener('message', (event) => client.ingest(event.data));Scripts
npm run lint # oxlint
npm run typecheck # tsc --noEmit
npm test # node:test
npm run format # prettier, then blank line spacing
npm run build # emit dist
npm run check # lint, typecheck, testStyle is single quotes and four space indents. Source imports use .ts specifiers, which TypeScript rewrites to the real runtime filename on build. That is an ESM requirement, not a CommonJS one: Node's ESM resolver does no extension guessing, so import './client' throws ERR_MODULE_NOT_FOUND and the specifier has to name the file that exists at runtime. The output is ESM only, with no CommonJS build and no require anywhere in dist.
Built with TypeScript 7 targeting es2025, the highest stable target Node 24 supports. Not esnext, because its meaning changes with every TypeScript release, so a toolchain bump would silently change the JavaScript consumers receive.
erasableSyntaxOnly keeps the source free of syntax that only a type-aware compiler can remove, so every file also runs under Node's native type stripping. isolatedDeclarations is on for the build, so declarations emit per file without whole-program inference.
npm run format runs Prettier and then scripts/spacing.mjs, which enforces one blank line between declarations and members and separates constants, blocks and returns. oxlint has no blank line rules, so the formatter owns that.
Releases run on Conventional Commits through release-please, which is a two step flow. A push to master opens or updates a release PR, it does not publish. Merging that PR is what tags the version, writes CHANGELOG.md, attaches the compiled and source archives, and publishes to npm. Set skip-github-pull-request: true in the release workflow if you would rather it tag straight from master.
Publishing uses npm trusted publishing over OIDC, so there is no token to manage. Configure the trusted publisher once in the package settings on npmjs.com, pointing at this repository and .github/workflows/release.yml. Versioning uses release-please in single package mode, so tags are plain vN.N.N. Trusted publishing is per workflow, so release.yml is the only workflow that publishes. Run it with a publish_tag input to republish an existing tag.
Tests run against a fake game over a real socket, so Rocket League is not needed. Fixtures are copied verbatim from the official docs.
License
MIT © devjmd
Unofficial. Not affiliated with Psyonix or Epic Games.
