Steam Charts#

In this tutorial we build a small but real app: the top 25 games on Steam by current players, live from steamcharts.com, with each game's last 30 days of activity drawn as a bar graph by the vector drawing API. It is the app side of OneJS: network requests, async data flowing into React state, list UI, and GPU-rendered custom graphics. By the end you will not have written a single line of C#.

Two endpoints power the whole thing. steamcharts.com/top is a static HTML table of the top 25 games, and steamcharts.com/app/{id}/chart-data.json is each game's player-count history as [[timestamp, players], ...]. They are community-run and unofficial, so be polite: we fetch the top page once and one small JSON file per listed game.

Every bullet point is an actionable step. Everything else is explanation.

Project setup#

  • Create a new scene, add an empty GameObject named App, add the JSRunner component, and click Initialize Project.

That scaffolds a TypeScript project next to the scene, runs npm install and the first build, and starts rendering in the Game view with live reload. The Nameplates tutorial walks this workflow in detail; here we go straight to the code.

  • OneJS ships fetch in the runtime, so just declare it for TypeScript. Create ~/types/app.d.ts:
interface FetchResponse {
    readonly ok: boolean
    readonly status: number
    readonly statusText: string
    readonly url: string
    text(): Promise<string>
    json(): Promise<any>
}

declare function fetch(url: string, options?: {
    method?: string
    headers?: Record<string, string>
    body?: string
}): Promise<FetchResponse>

Fetch and parse the top list#

Start ~/index.tsx with the data layer:

import { useEffect, useState } from "react"
import { render, View, Text, ScrollView, Button, useBatchedVectorContent } from "onejs-react"
import "onejs:tailwind"

type Game = {
    rank: number
    appId: number
    name: string
    current: number
    peak: number
}

const TOP_URL = "https://steamcharts.com/top"
const chartUrl = (appId: number) => `https://steamcharts.com/app/${appId}/chart-data.json`

function parseTop(html: string): Game[] {
    const games: Game[] = []
    for (const row of html.split("<tr")) {
        const link = row.match(/<a href="\/app\/(\d+)">\s*([^<]+?)\s*<\/a>/)
        if (!link) continue
        const current = row.match(/<td class="num">(\d+)</)
        const peak = row.match(/peak-concurrent">(\d+)</)
        if (!current || !peak) continue
        games.push({
            rank: games.length + 1,
            appId: Number(link[1]),
            name: decodeEntities(link[2]),
            current: Number(current[1]),
            peak: Number(peak[1]),
        })
    }
    return games
}

function decodeEntities(s: string): string {
    return s
        .replace(/&amp;/g, "&")
        .replace(/&#39;/g, "'")
        .replace(/&quot;/g, '"')
        .replace(/&lt;/g, "<")
        .replace(/&gt;/g, ">")
}

function formatCount(value: number): string {
    return String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

function formatCompact(value: number): string {
    if (value >= 1e6) return (value / 1e6).toFixed(2) + "M"
    if (value >= 1e3) return (value / 1e3).toFixed(1) + "k"
    return String(value)
}

There is no DOM in QuickJS, so the top page is parsed with two small regexes against the fixed table shape: each game row has an /app/{id} link followed by num cells for current players and peak. Splitting on <tr first keeps each match scoped to one row.

The onejs:tailwind import turns on the built-in Tailwind generator; the class names used below compile to USS at build time. One rule to know: generation scans your source for literal class names, so a name assembled from a variable silently produces no style.

Draw the 30-day bars#

chart-data.json covers each game's whole life: monthly points back to 2012, daily points for recent months, and hourly points for the last ~30 days. Averaging the hourly tail into one value per day gives 30 bars:

function toDailyBars(points: [number, number][], days = 30): number[] {
    const end = points[points.length - 1][0]
    const start = end - days * 86400000
    const sum = new Array(days).fill(0)
    const n = new Array(days).fill(0)
    for (const [ts, players] of points) {
        if (ts < start) continue
        const i = Math.min(days - 1, Math.floor((ts - start) / 86400000))
        sum[i] += players
        n[i]++
    }
    return sum.map((s, i) => (n[i] ? s / n[i] : 0))
}

Drawing them is the vector API's job. Any element can render custom GPU geometry, and the batched painter records the whole draw into one buffer that crosses into C# once, instead of one reflection crossing per path call:

function BarGraph({ values, width, height }: { values: number[], width: number, height: number }) {
    const max = Math.max(...values, 1)
    const ref = useBatchedVectorContent(p => {
        const slot = width / values.length
        const barW = slot * 0.7
        p.fillColor(0.4, 0.75, 0.96, 0.9)
        p.beginPath()
        for (let i = 0; i < values.length; i++) {
            const h = Math.max(1, (values[i] / max) * height)
            const x = i * slot + (slot - barW) / 2
            p.moveTo(x, height)
            p.lineTo(x, height - h)
            p.lineTo(x + barW, height - h)
            p.lineTo(x + barW, height)
            p.closePath()
        }
        p.fill()
    }, [values])
    return <View ref={ref} style={{ width, height }} />
}

Every bar is a closed subpath of a single path, filled once at the end: one fill() call, one buffer, one crossing, thirty bars. useBatchedVectorContent returns a ref that wires the element's generateVisualContent and repaints when the deps change. Coordinates and colors are plain numbers; no new Vector2 per corner.

Wire it together#

The rest is ordinary React. One effect fetches the top page, then fires all the history fetches; each row's graph fills in as its response lands:

function GameRow({ game, bars }: { game: Game, bars: number[] | null }) {
    return (
        <View className="flex-row items-center px-4 py-2 border-b border-gray-800 hover:bg-gray-800">
            <Text className="w-8 text-gray-500 text-sm" text={`${game.rank}`} />
            <View className="grow pr-3">
                <Text className="text-gray-100 text-base" text={game.name} />
                <Text className="text-gray-500 text-xs mt-1" text={`peak ${formatCompact(game.peak)}`} />
            </View>
            <Text className="w-24 text-right text-sky-300 text-base pr-4" text={formatCount(game.current)} />
            <View className="w-32">
                {bars
                    ? <BarGraph values={bars} width={128} height={36} />
                    : <View className="h-9 justify-center"><Text className="text-gray-600 text-xs" text="loading..." /></View>}
            </View>
        </View>
    )
}

function App() {
    const [games, setGames] = useState<Game[] | null>(null)
    const [series, setSeries] = useState<Record<number, number[]>>({})
    const [error, setError] = useState<string | null>(null)
    const [generation, setGeneration] = useState(0)

    useEffect(() => {
        let stale = false
        setGames(null)
        setSeries({})
        setError(null)

        const load = async () => {
            try {
                const res = await fetch(TOP_URL)
                if (!res.ok) throw new Error(`HTTP ${res.status}`)
                const top = parseTop(await res.text())
                if (stale) return
                if (top.length === 0) throw new Error("no games parsed")
                setGames(top)

                for (const game of top) {
                    fetch(chartUrl(game.appId))
                        .then(r => r.json())
                        .then((points: [number, number][]) => {
                            if (!stale) setSeries(prev => ({ ...prev, [game.appId]: toDailyBars(points) }))
                        })
                        .catch(() => { /* row keeps its placeholder */ })
                }
            } catch (e: any) {
                if (!stale) setError(String(e?.message ?? e))
            }
        }
        load()
        return () => { stale = true }
    }, [generation])

    return (
        <View className="grow bg-gray-900">
            <View className="flex-row items-center px-4 py-3 border-b border-gray-700 bg-gray-950">
                <View className="grow">
                    <Text className="text-gray-100 text-lg" text="Steam Charts" />
                    <Text className="text-gray-500 text-xs mt-1" text="Top games by current players · last 30 days activity" />
                </View>
                <Button
                    text="Refresh"
                    className="px-3 py-1 rounded-md bg-gray-700 text-gray-200 hover:bg-gray-600"
                    onClick={() => setGeneration(g => g + 1)}
                />
            </View>

            {error && (
                <View className="m-4 p-3 bg-red-950 border border-red-800 rounded-md flex-row items-center">
                    <Text className="grow text-red-300 text-sm" text={`Could not load steamcharts.com (${error})`} />
                    <Button
                        text="Retry"
                        className="px-3 py-1 rounded-md bg-red-900 text-red-200 hover:bg-red-800"
                        onClick={() => setGeneration(g => g + 1)}
                    />
                </View>
            )}

            {!games && !error && (
                <View className="grow items-center justify-center">
                    <Text className="text-gray-500" text="Fetching steamcharts.com..." />
                </View>
            )}

            {games && (
                <ScrollView mouseWheelScrollSize={12} className="grow">
                    {games.map(game => (
                        <GameRow key={game.appId} game={game} bars={series[game.appId] ?? null} />
                    ))}
                </ScrollView>
            )}
        </View>
    )
}

render(<App />, __root)

Save, and the Game view is a live dashboard. The load sequence is worth watching once in slow motion: the loading state, then the ranked rows the moment the top page parses, then graphs popping in one by one as each history response arrives:

A few things worth knowing:

  • Progressive data, not a spinner wall. The rows render as soon as the cheap request finishes; the 25 history fetches fill in behind it. Each response updates one key in series, so a row re-renders exactly when its own data lands.
  • The stale flag makes refresh safe: bumping generation re-runs the effect, and the cleanup from the previous run stops late responses from writing into the new state.
  • Failure is a state, not an exception. A dead network shows the error banner with a retry; a single failed history request just leaves that row's placeholder.
  • WebGL note: steamcharts.com sends no CORS headers, so a browser build cannot fetch it directly. In the editor and native builds, fetch rides UnityWebRequest and CORS does not apply.

Add thumbnails#

Steam's CDN serves every store game's header art at a predictable address keyed by the same appId we already have. Almost: newer titles keep their art under hashed store_item_assets paths that only the store API knows, and a few tracked apps (FiveM-style launchers) have no store page at all. So the reliable recipe is a chain: try the predictable URL, fall back to one appdetails lookup, and give up gracefully.

  • Add the loader next to the other data functions:
import { loadImageAsync } from "onejs-unity/assets"

const headerUrl = (appId: number) =>
    `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/header.jpg`

async function loadThumb(appId: number): Promise<any | null> {
    try {
        return await loadImageAsync(headerUrl(appId))
    } catch { /* fall through */ }
    try {
        const res = await fetch(`https://store.steampowered.com/api/appdetails?appids=${appId}&filters=basic`)
        const data = await res.json()
        const url = data?.[appId]?.data?.header_image
        if (url) return await loadImageAsync(url)
    } catch { /* fall through */ }
    return null
}

loadImageAsync (from onejs-unity/assets) downloads the image and hands back a Texture2D, throwing on a miss, which is what makes the try/catch chain read cleanly.

  • Add a Thumb component and slot it into GameRow right after the rank:
function Thumb({ name, tex }: { name: string, tex: any }) {
    return (
        <View className="rounded bg-gray-800 items-center justify-center mr-3"
            style={{ width: 64, height: 30, backgroundImage: tex ?? undefined }}>
            {!tex && <Text className="text-gray-600 text-sm" text={name.slice(0, 1)} />}
        </View>
    )
}

The texture goes on a View as backgroundImage rather than an Image element because background images respect the corner radius. Until art arrives (or forever, for an app that has none) the tile shows the game's initial.

  • Wire it like the graphs: a thumbs state record, one loadThumb(game.appId).then(...) per game inside the same loop that fires the history fetches, and a thumb={thumbs[game.appId] ?? null} prop on each row.

Same progressive pattern, third data stream, no new concepts. That is the version in the videos above.

Where to go next#

  • The full request surface (POST, headers, timeouts): Fetch API
  • Everything the painter can draw, including the raw Painter2D API: Vector Drawing
  • The utility classes used here and how generation works: Tailwind