npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

busybar-dota

v1.3.0

Published

Live Dota 2 pro match on BUSY Bar

Downloads

663

Readme

busybar-dota

[!IMPORTANT] Unofficial community project. Built and maintained by @viirtualp1, not an official Flipper Devices / BUSY product, and not affiliated with, endorsed by, or supported by them. "BUSY Bar" remains their trademark. For the real hardware and official apps, visit busy.app.

Live Dota 2 pro matches on a BUSY Bar. Built for The International, works for any pro game.

What you get

Front — the front display is the net worth bar

The 72 pixels are split between the two teams by net worth, not by the kill score — Radiant green from the left, Dire red from the right, kills in bold on top. So a 1-1 game with a 3k gold lead shows a green-heavy bar, and that is the point: you read who is actually winning from across the room without reading a single number.

  • Kill score, big and centred, with the series score directly beneath it
  • Team tags in their side's colour
  • Game clock (negative during the pre-horn countdown, like the game itself)
  • Who is ahead on gold, bottom right — +9.6k, coloured green or red for the side that is ahead. No team tag: it would collide with the centred series score, and colour is what the front panel is for. Blank under 500 gold, since a number that flips teams every poll is worse than no number
  • Event ticker: when a tower, barracks or Roshan goes down, the bottom row hands itself over — FLC lost mid tier 2 tower, Roshan killed, respawns in 9 min — with an LED flash and a sound, then gives the clock back. Anything too long to fit scrolls rather than being abbreviated: FAL MID RAX saved pixels and lost the meaning. Kills appear silently, because there are dozens of them and one chirp each is a machine gun.

Long lines page, they do not scroll

Nothing is abbreviated any more — FAL MID RAX saved pixels and lost the meaning — so anything past seventeen glyphs has to be shown over time. It is broken on word boundaries and each chunk holds still for 2.2 seconds.

How many glyphs fit is a guess — the device fonts are proportional and this code cannot measure them, so it budgets 4px each and gets 17. TICKER_CHARS raises that if lines page when they plainly had room to sit still.

Scrolling was tried first and read badly, for a reason worth writing down: the display redraws about five times a second. A step small enough to look smooth is one pixel, which crawls at 5px/s; a step fast enough to finish a sentence moves a whole 4px glyph at a time, which reads as a stutter rather than motion. The original 180ms step against a 200ms redraw was not even a whole multiple, so steps landed on alternating frames and visibly jittered. TICKER_STYLE=scroll brings the moving line back, now with its step aligned to the redraw.

Back

  • Both team names, net worth lead and standing towers
  • Five rows, Radiant on the left and Dire on the right: hero + K/D/A. The name alternates between hero and player every three seconds, because the row has space for one and you want both.
  • The sub-line carries the full event while the ticker runs — FAL lost mid barracks — since there is room for the detail the front cannot fit.
  • During the draft the same rows show picks in draft order, and the subtitle shows the ban count plus each side's most recent ban. The front clock reads DRAFT instead of counting down to a horn that has not been scheduled yet.

When a game ends — the result takes the screen

For two minutes the display is only about who won: both tags in bold with the winner knocked out of a filled block, the series score between them, and the next scheduled match on the bottom row. The loser is dimmed rather than hidden, because you want to read the pairing. Then the countdown comes back.

The winner is marked by a filled block rather than an outline for a boring reason: 16 pixels cannot hold a border, a bold tag inside it and a line of text beneath without the border cutting through glyphs.

The winner is not in the live feed — a finished game simply stops being listed, and the series score it carried was the score going into that game. It is resolved from OpenDota's /matches/{id}, which needs no key. Until that lands the screen says GAME OVER and result pending rather than guessing.

Between games — countdown, start time and bracket

When nothing is live the countdown takes the top row and the matchup sits under it, spelled out: Team Spirit VS Falcons before a series starts, and Team Spirit 1-0 Falcons once one is under way — the score simply stands where VS did. All of it static: a line that paged between game 1, game 2, game 3 was the hardest thing on the display to read.

If the full names do not fit the line budget the pair steps down to tags — TS 1-0 FLC — rather than clipping a name or paging it. At the conservative default of 17 glyphs most real team names will take that fallback; raise TICKER_CHARS until they stop, since the device font is very likely narrower than the 4px a glyph this code has to assume.

The back display carries the date, time and series length of the next match, then the rest of the known schedule: start time and matchup on each row, the next one brighter than the ones after it. A later day is named on that row so two 06:00s are not confused. Finished games stay off this list.

This needs a schedule source, and there is not a free one — see below.

Requirements

Setup

npm install
cp .env.example .env
npm run dev

Leave the Bar on a BUSY / CUSTOM session, or its own session outranks the draws (see DRAW_PRIORITY).

There is no live match right now

Games only run during the tournament's local daytime, which is a poor development loop. Run a synthetic match instead:

npm run demo

The demo also decides its own winner — the synthetic match has no id anyone can resolve, so without that its result screen would sit on GAME OVER with nobody marked, which is the part of that screen worth looking at.

It plays a believable game rather than a stress test: a draft, then thirty minutes ending 17-12, towers falling in a plausible order, Roshan twice, mid barracks right at the end, and then the game stops being live so the between-games view gets exercised too. The whole thing runs in about a minute and a half at 20x.

Screenshots without a Bar

npm run shot               # the synthetic match, mid-game
npm run shot -- --draft    # the draft phase
npm run shot -- --upcoming # the between-games countdown and bracket
npm run shot -- --break    # the pause between games of a series
npm run shot -- --event    # the ticker, caught on the barracks falling
npm run shot -- --live     # whatever is actually live right now

Writes preview-front.png (72×16) and preview-back.png (160×80), scaled 8× with square pixels, and prints the same frame as text.

It rasterises the same element array that goes over the wire, so a layout bug appears here exactly as it would on hardware — with two deliberate caveats:

  • Glyphs are approximate. The Bar renders real TTFs baked to a glyph atlas and there is no way to reach those from Node, so the preview uses a built-in 3×5 font and renders everything upper case. It will tell you that a column overflows or that the wrong value is on screen; it will not tell you that a letter is a pixel narrower on hardware.
  • The back display is flattened to 16 greys, because that is what the panel is. This is the point, not a shortcut: it is what showed that Radiant green and Dire red collapse into two nearly identical greys on the back, which is why the rosters are separated by a divider rule instead of by colour.

For pixel-exact front rendering there is the community busybar-emulator, which speaks the same HTTP API with the device's real fonts — point BUSY_ADDR at it. It does not implement the back display, which is the half this preview exists for. busy-lib ships a ScreenRenderer with the same limitation, and it is canvas-bound so it will not run in Node at all.

Data sources

Two upstreams, picked automatically:

| | Steam GetLiveLeagueGames | OpenDota /live | | ---------------- | -------------------------- | ---------------- | | Key | free key required | none | | Per-player K/D/A | yes | no | | Picks and bans | yes | no | | Tower state | yes | yes | | Series score | yes | no |

Set STEAM_API_KEY for the full back display. Without it the app falls back to OpenDota and the roster shows heroes only — the front display is identical either way.

With no LEAGUE_ID or MATCH_ID set, it follows whichever pro game has the most spectators. During TI that is reliably the main stage, which is why it is the default rather than a hardcoded league id that goes stale every year.

Schedules and brackets: a hand-maintained file, for now

Valve's GetScheduledLeagueGames is gone — it returns 404, as does GetLeagueListing. Nothing keyless publishes a pro schedule or bracket, and the paid-or-invite options are all blocked right now: Liquipedia moved brackets off its basic tier, and STRATZ sits behind Cloudflare. So the schedule is a file:

json — a file you maintain (the default, and what actually works today)

cp schedule.example.json schedule.json
npm run schedule:check

The format is built around the thing that actually costs time: converting tournament times into your own. It does not ask you to.

{
  "timezone": "Asia/Shanghai",
  "date": "2026-08-20",
  "matches": [
    {
      "teams": "IW vs TSpirit",
      "time": "10:00",
      "stage": "Upper Bracket Quarterfinals",
      "bo": 3
    },
    {
      "teams": "VISION vs BB",
      "time": "13:00",
      "stage": "Upper Bracket Quarterfinals",
      "bo": 3
    }
  ]
}
  • timezone and date are set once per file. Set timezone to whatever zone your source displays, not the tournament's — Liquipedia converts times to your own zone when you are logged in, so copying what you see is usually right. Then each match is just a wall-clock time and there is nothing to convert at all. Zone names resolve through Intl, so DST is handled rather than approximated by a fixed offset.
  • teams is one field. "IW vs TSpirit", not a teamA/teamB pair. Tags and the short stage label (Upper Bracket R2 → UB2) are derived.
  • The bracket is derived from the matches, so there is one list to keep straight instead of two that have to agree. Supply a bracket array only to override it.
  • Finishing a match is one edit: add "score": "2-0". It drops out of the countdown and shows its result in the bracket.
  • The file is re-read whenever it changes, so you can edit it while the app runs.

npm run schedule:check prints the parsed file with tournament time and your local time side by side:

  Asia/Shanghai         Asia/Tbilisi          in        stage   match
> 20 Aug, 10:00         20 Aug, 06:00         3d 13h    UBQ     IW vs TSpirit  BO3
  20 Aug, 13:00         20 Aug, 09:00         3d 16h    UBQ     VISION vs BB  BO3

That column pair exists because the failure mode of a hand-written schedule is a time that is off by a timezone: it looks perfectly fine in the file and only shows up hours later as a countdown to the wrong thing. When the file's zone is already your own, the second column is dropped and it says so.

Bad files fail loudly and name the field: matches[2].time "lunchtime" is not a time I can read (expected HH:MM). A mistyped zone is caught at load rather than silently treated as UTC.

The longhand form (teamA, teamB, startsAt with a full ISO string) still works for one-offs.

Note that the bar shows start times in your local zone, not the tournament's — it is telling you when to be at your desk.

For reference, the options and why they are not wired up:

| Source | What it takes | Notes | | ----------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Liquipedia API v3 | free API key, requested from them | best data fit — matches, brackets, tournaments. Returns 403 without one, and their MediaWiki api.php is closed off (406). | | STRATZ GraphQL | free token | rich league/bracket schema, but the endpoint sits behind Cloudflare | | PandaScore | account, free tier unclear | clean REST: /matches/upcoming, /tournaments/{id}/brackets |

So ScheduleSource is an interface (src/dota/schedule.ts) with a demo implementation, and no real one is wired up yet. Without one the app shows the plain idle screen — it will not invent a countdown.

Hero names come from OpenDota at startup rather than a vendored table: the roster grows every patch, and a stale table shows #145 for exactly the heroes a new patch's tournament is about. If that request fails the display degrades to hero ids instead of refusing to start.

Configuration

| Variable | Default | Notes | | -------------------- | --------------- | ------------------------------------------------------------------- | | BUSY_ADDR | 10.0.4.20 | USB address; Bar LAN IP for Wi-Fi; https://api.busy.app for cloud | | BUSY_TOKEN | — | cloud only | | BUSY_HTTP_PASSWORD | — | Wi-Fi only (Bar web UI → Network → HTTP API access) | | STEAM_API_KEY | — | unlocks per-player stats | | LEAGUE_ID | — | pin to one tournament | | MATCH_ID | — | pin to one game | | POLL_MS | 5000 | upstreams refresh every few seconds | | FRAME_MS | 200 | redraw cadence | | DRAW_PRIORITY | 40 | | | SCHEDULE_SOURCE | auto | json / demo / none | | SCHEDULE_FILE | schedule.json | hand-maintained schedule file | | DEMO | — | 1 for the synthetic match |

Notes

You are watching the past. Both sources carry the tournament's broadcast delay, typically two minutes. The bar is in sync with the stream, not with the players.

Polling is deliberately slow. OpenDota allows 60 requests a minute and the upstream numbers only change every few seconds, so a faster poll would spend quota to redraw identical frames. The render loop runs independently and much faster, so LED flashes stay crisp.

Upstream hiccups keep the last frame. A failed poll leaves the previous numbers on screen rather than blanking the display — slightly stale beats empty.

Changing the wording

Every sentence that reaches the display is in one block at the top of src/domain/events.ts — EVENT_TEXT for the events themselves and BUILDING_WORDS for how lanes and tiers are named. Nothing below that block builds a sentence of its own, so rewording is a one-file edit.

Keep it plain ASCII. The Bar draws a placeholder box for glyphs its font lacks, so an em dash or a middle dot arrives as a stray rectangle mid-sentence — which is exactly what the first version did. A test walks every event and every drawn line and fails on any character above ASCII 126.

Things worth building next

  • A Roshan countdown on screen, not just the kill notice — the timer is already parsed
  • Who killed whom: per-player kill and death counters are in the Steam scoreboard, but pairing them into "X killed Y" is guesswork when several land in one five-second poll, so only the team is reported today
  • Bans as a grid of hero portraits — the back display has the room once picks are done, but it means uploading images to device assets on every change
  • Per-player net worth bars instead of plain K/D/A text