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

ojplay

v0.10.0

Published

OJPlay from the terminal: add carts to carts, build, run and test them like the site does. The runtime carts import as "oj".

Readme

ojplay

OJPlay from the terminal, and the container runtime its carts run on. Published to npm as ojplay (until 1 Oct 2026, onejs-play, which stays frozen at 0.8.9 for the OneJS projects that pin it; this repository keeps that name). Installed once, npm install -g ojplay, it is a command: ojplay add @handle/name. Games import the runtime as oj through an esbuild alias. The npm name oj was already taken, and the alias is the same mechanism OneJS already uses to dedupe React, so it ships in the scaffolded esbuild config and survives eject.

The design rule

oj is a strict subset of OneJS, never a variant. Everything here also works in a normal Unity OneJS project, so ejecting a game is: copy the file into a scene folder, npm i, build. Zero diff.

Every proposed feature answers one question: does this also work in a real OneJS project? If not, it is cut, or it degrades to a documented no-op after eject.

What is here

| Module | Contents | |---|---| | index.ts | The game-facing surface, aliased to oj | | mount.ts | mount() and useStage() | | frame.ts | useFrame, the per-frame clock a game runs on | | gesture.ts | useSwipe, read off input once per frame | | stage.ts | The stage (the window, in logical pixels) and Unity screen space into it | | asset.ts | assetUrl, loadTexture, useTexture, useFlipbook, loadSheet: a game's own files | | audio.ts | onejs-unity's audio, with load taking a bare file name, and useSound | | scores.ts | scores and useLeaderboard | | room.ts | useRoom: other people, over a relay | | wire.ts | What each relay message does to a room's state (peers, host), kept testable apart from the socket | | physics.ts | usePhysics, with the per-frame pumping done | | mathf.ts | Mathf, Unity-shaped, implemented in JS | | vec.ts | Vector2. No Vector3: a 3D position is a plain [x, y, z] | | models.ts | useScene, useModel: .glb models behind the panel, with a sun, shadows and lights | | color.ts | Color, parsing through onejs-react's toRGBA | | transform.ts | onejs-react's Transform2D, with point() returning oj's Vector2 | | random.ts | random: a shared generator, and called with a seed, a seeded one | | theme.ts | The default look of the controls the runtime provides | | code.ts, code-view.tsx | tokenize and <Code>: TypeScript highlighting for a game that shows source | | container.ts | The host-facing surface, ojplay/container | | runtime.ts | Container-side: builds the oj object a game receives | | play.ts | Container-side: where the site is, and the token proving a real session | | sandbox.ts | Container-side: keeps a game bundle off the runtime's globals | | input.ts | Container-side: the input backend onejs-unity reads through | | adapter.ts | Container-side: browser events into that backend | | standalone.ts | Starting a runtime outside a container, which is what eject needs | | hostinput.ts | Outside a container: the real InputBridge with pointer reads converted to stage pixels |

Beside src/, two folders that are not the runtime:

| Folder | Contents | |---|---| | build/ | game.mjs, the one builder every game is built with (the site on publish and on Run, ojplay build, the shipped-games script), and externals.json, the modules the container provides. The esbuild instance is a parameter, wasm in the Worker and native here. | | cli/ | The oj command line, the package's bin. | | host/ | boot.mjs, the script that boots the container and hands it a game, inlined by the site's sandbox document and by the page ojplay run serves. One copy of the contract with __ojPlay.load; the two documents differ only in where the runtime is, how the bundle arrives and whom they tell. |

Everything above the container.ts line is reachable from a game. Everything below it is the host's, and a game bundle cannot see it: oj is the package root and ojplay/container is a separate entry point.

Value types are pure JavaScript, never bridged. That is faster than the real thing (no reflection crossing, no handle-table entry) and it keeps the container decoupled from Unity's API surface.

Unity parity is verified, not assumed

Mathf, Vector2 and Color were checked against the decompiled UnityEngine assemblies for 6000.5.2f1 via unity_reflection_decompile, not written from memory. That caught two real bugs that looked right and tested green:

  • Vector2.Angle guards the product of squared magnitudes against 1e-30 before the square root. Guarding the root against kEpsilon instead is ten orders of magnitude too strict and returns 0 for two perfectly ordinary vectors of magnitude 1e-3.
  • normalized is written !(m > kEpsilon), not m <= kEpsilon, which also sends NaN to zero rather than dividing by it and seeding NaN into every coordinate downstream.

Re-verify with the same tool when bumping Unity versions.

The stage

The stage is the window, in logical pixels, and useStage() returns its { width, height }, re-rendering when it changes. There is no fixed logical size and no fit: UI Toolkit is the renderer, so a game lays itself out against the window and reflows, the way a page does. An oj.json stage block is no longer read.

A game that wants a fixed board builds one in its own code: a fixed-size View with scale set to fit the window, centred.

const W = 960, H = 540

function useBoard() {
    const { width, height } = useStage()
    const scale = Math.min(width / W, height / H)
    const left = (width - W * scale) / 2, top = (height - H * scale) / 2
    return {
        scale,
        style: { position: "absolute", left: (width - W) / 2, top: (height - H) / 2, width: W, height: H, scale } as const,
        toBoard: (p: { x: number; y: number }) => ({ x: (p.x - left) / scale, y: (p.y - top) / scale }),
    }
}

UI Toolkit picks through the scale, so handlers on elements inside the board need nothing. Anything reported in window pixels (e.x, input.mouse.position, touch positions) goes through toBoard; localX and deltas divide by scale. Measured in the real container at four window sizes, scales 0.41 to 1.33, with every click landing in the right cell.

Logical pixels are CSS pixels: the container scales the panel by devicePixelRatio, and nothing else. Fullscreen changes how many pixels there are. The host page owns the Fullscreen API call so the user gesture and the Permissions Policy stay on its side of the iframe boundary.

Input

There is no oj.input. Games call onejs-unity's input, the same API a normal OneJS project uses, so game code reads identically here and after eject.

import { input } from "oj"

if (input.keyboard.wasKeyPressed("Space")) jump()
const p = input.mouse.position     // stage pixels, as a pointer event reports them

That module normally reads UnityEngine's InputBridge through CS, which the container shadows. So createContainerInput() in ojplay/container supplies the same methods from browser events, and the host installs it with onejs-unity's setInputBackend. One API, one implementation, a swappable source. Writing a second input API here would have been the maintenance nightmare in miniature.

Pointer events and input report the same numbers.

| | Reports | |---|---| | input.mouse.position, input.touches[n].position | stage pixels | | onPointerDown and friends: x, y | stage pixels | | onPointerDown and friends: localX, localY | relative to the element the handler is on |

localX and localY are what a handler hit testing against its own box wants; they come off the synthetic event's prototype in the OneJS bootstrap and read worldBound on first use, so a handler that never asks pays nothing. Before they existed, a handler typed as any accepted localX happily and got undefined: Patience shipped with every card unclickable because of exactly that. Its ChangeEventData sibling carries value, not newValue, which broke every slider in Particle Lab the same way.

Reading through input also gets touch for free, since the same code sees input.touches. A swipe is useSwipe(onSwipe) from gesture.ts: it follows one finger or a mouse drag through input, fires once per gesture past a threshold, and ignores the mouse while a finger is down, because the container reports a touch as the mouse too and listening to both fires everything twice. Twos Company carried that state machine itself before it moved here.

Hooks read the latest render. useFrame and fx.useAnimation call the callback from the most recent render on every frame, so a callback can read state and props directly and the dependency list only says when to resubscribe or restart the clock. The first version froze the first render's closure for the life of the component, and every game with a slider ended up mirroring its state into a ref to get around it.

One frame clock. useFrame is onejs-react's, so a cart and a OneJS app share the hook. createRuntime installs the runtime as its clock with setFrameClock, and dispose puts the default back, so frames stop when the container stops calling beginFrame and a callback sees this frame's input edges. useDrawing(ref, draw, "frame") repaints on the same clock.

Breakpoints describe the stage. mount() wraps the game in a ScreenProvider sized from the stage, so useBreakpoint and friends work in a game with no setup.

A pointer used to be wrong after an eject

Fixed. Kept here because the shape of the bug is the useful part.

createRuntime installs the container's input backend, because that is what the container needs, and startStandalone calls createRuntime. So an ejected game got that backend with nothing feeding it: no adapter pushes browser events into it in a Unity project, and there are no browser events to push. Every key read as up, the mouse sat at the origin, no touch ever arrived. Not a wrong answer, an answer that never changed. standalone.ts documented the opposite, which was its intent and not its behaviour.

Installing nothing would have been half a fix. onejs-unity then falls through to the real InputBridge, so input works, and reports Unity screen space: physical pixels, origin at the bottom left, y counting up, against a game laid out in logical pixels from the top left with y counting down. Two differences at once, and the flipped axis reads as a haunting rather than a bug.

So hostinput.ts wraps the real bridge rather than replacing it. Keyboard, gamepad and haptics pass straight through, because they were never in a coordinate space; the pointer methods convert through screenToStage, and only those. A delta converts separately from a position: a delta has no origin, and running one through the position path adds the viewport height to every vertical movement, which still looks like it works until something crosses the middle of the screen.

The arithmetic is unit tested and does not need Unity. What still does is that the bridge is reachable at all, which is the one thing those tests cannot prove.

A project without the Input System has no bridge. OneJS compiles InputBridge only when the package is installed and its backend is on, and the CS proxy answers CS.OneJS.Input.InputBridge either way, so the first read on a missing one failed with Type not found on every frame. bridgeFrom asks System.Type.GetType instead, which answers null without logging, and the game then gets IDLE_INPUT (nothing pressed, pointer at the origin) and one warning saying how to turn input on. Checked in a Unity project both ways: no warning with the Input System, one warning and no error without it.

Input actions are not covered. A backend is not consulted for them, so an ejected game using an action reaches the real bridge directly. Correct for everything except a position read out of an action, which will be in screen space. Narrow, and written down rather than papered over.

A game's own files

Until recently a game was text and nothing else: no sprite, no sound, no font. Now it can ship them, and assetUrl is the one function that knows where they went.

import { Image, useTexture, useSound } from "oj"

const glow = useTexture("glow.png")   // in a component: a Unity texture, or null
const pop = useSound("pop.wav")       // a sound, or null; unloaded with the component
<Image src="logo.png" />              // Image takes the bare name too

A bare file name, resolved differently on each side of an eject: on the site to /assets/<name> on the game's own origin, and in a Unity project to the project's assets/ folder in the editor or StreamingAssets/onejs/assets/ in a build. The container passes its origin in as assetBase when it creates the runtime; with none set, OneJS's own project convention applies, which is exactly what an ejected copy needs.

Where the files go. An asset's name is its path from the cart's root: pop.wav beside index.tsx, or sfx/pop.wav in a folder of your own. Never in a folder called assets/: assetUrl strips a leading assets/ (the habit a web developer arrives with), so the site stores such a file and never serves it. Images are png or jpeg, and names are case-sensitive. ojplay run and oj test serve /assets/ by the same rule (cli/assets.mjs, a copy of the site's media.ts), warn at start about any asset no request can reach, and print why each refused request was refused. fireworks and one-note kept their sounds in assets/ and were silent locally and live until this (#3).

Explicit at the call site on purpose. Teaching every loader a hidden base would mean a bare "glow.png" resolving through machinery a reader cannot see, and two loaders that disagreed about it would be a bug with no visible cause.

loadTexture, useTexture, useSound and audio.load all take the bare name. oj's audio (audio.ts) is onejs-unity's with load resolving a name through assetUrl; a URL passes through untouched. useSound unloads its sound with the component, a sound that arrives after the unmount included.

Two things had to be fixed in the runtime before any of this worked, and both were invisible from the outside:

  • onejs-react's image loader had no URL bypass, so a full URL was mangled into {streamingAssets}/onejs/assets/https://... and never fetched. onejs-unity's own resolver has always had that check; the copy in the reconciler did not. Not a WebGL bug, though it was found here: https://... is not a rooted path on any platform, so the same mangling happened in the editor and in a desktop build. It only went unnoticed because nothing had loaded a remote image before.
  • On WebGL, QuickJSUIBridge.Tick() is never called, because the browser drives the JS scheduler instead. Settling completed C# Tasks lived in Tick(), so on WebGL every Task-returning API stayed pending forever: audio.load and <Image src="http..."> never resolved and never rejected, in every web build, with nothing logged. It is settled from TickSystems() now, which is the one thing Update does still call.

3D models

A game loads a .glb with useModel, gets a scene with useScene, and spawns copies of the model into it. Every call is a handle into OneJS.Models.ModelBridge (OneJS Runtime/Models/), which glTFast backs; the game never touches a GameObject.

The rule for the API is that a game opts out, never in. A scene with no options has a camera, a sun casting soft shadows, a hemisphere ambient, and models that cast and receive shadows. SCENE_DEFAULTS is that list, and each option on useScene and spawn turns one entry off or tunes it, named as three.js and Unity name them. The user page is PlaySite's docs/3d.md.

A cart has one scene: a second createScene in the same runtime throws and names the fix, while a scene left behind by an earlier runtime is disposed, because the container tears a cart down before it disposes the runtime. Each model file loads once per scene and is shared, frozen, by every spawn; a model loaded for a scene that is gone refuses to spawn rather than spawn into the next cart's world. Every actor's fade runs at its speed, and at 0 while scene.paused, which is also what ModelBridge.SetSpeed applies to clips.

Three things that are not obvious from the code:

  • One shader in both hosts. Every glTF material is built on OneJS's ModelLit, a URP PBR shader, by ModelMaterialGenerator. The site's container and a Unity project after ojplay add therefore draw the same pixels from the same file, which glTFast's own materials would not guarantee. The shader has a SubShader only where glTFast is installed, so a project without it builds nothing extra.
  • Play mode takes over, the preview does not. In play mode and in players the bridge switches off the scene's screen cameras and suns and sets the ambient and fog, then restores all of it when the scene is disposed. In the edit-mode preview it only adds objects of its own and leaves the user's scene and RenderSettings alone, so the preview is lit by the scene's own sun.
  • The panel goes transparent while a scene is live. mount reads its backdrop through useBackdrop, which is an external store rather than an effect, because a scene is created in a child's effect, which React runs before the stage's.

ojplay add of a cart with a .glb adds com.unity.cloud.gltfast to the project's Packages/manifest.json (ensureGltfast in cli/unity.mjs), so the project runs on its next open.

Other people

useRoom(name) puts a game in a room with everybody else playing it.

const room = useRoom("lobby", {
    onOpen: (myId, peers) => {},
    onJoin: (id) => {},
    onLeave: (id) => {},
    onMessage: (from, data) => {},
})
room.send({ x, y })       // to everyone else
room.send({ x, y }, id)   // to one peer
room.id, room.peers, room.connected, room.isHost, room.hostId

The site is a relay and runs no game logic, because a game here is a JavaScript bundle and half of it living on a server would end "fork this and change it". The rule that makes a dumb relay safe is worth stating twice:

Every client is the authority on itself and on nothing else.

You broadcast where you are; you decide when you died. A kill is not a message anybody can send, so the worst a liar can do is refuse to die, which makes them strange to watch and harms nobody else's game. Let the bigger player declare the kill instead and you have handed every client the ability to eat anyone at any distance.

Shared state that needs one owner (a food field, a round timer) goes to the host, and the room decides who that is: room.isHost, room.hostId, and onHost when it changes. It used to be "the lowest peer id present", evaluated by each game, and that elected sockets whose browser had already gone: only the relay knows who is still connected. The header of room.ts has the story.

Budgets: 24 peers a room, 8 KB a message, 60 messages a second a socket. Send position at about 15 Hz and interpolate between updates rather than sending every frame.

examples/big-fish is the reference, and its ocean.ts header is the longer version of the argument above.

Leaderboards

const board = useLeaderboard({ limit: 6 })   // board.entries: {name, score, at}[]
if (scores.available) board.submit(points)   // never throws

Submitting needs a short-lived token the host mints when it serves the game's document, checked against the game it was minted for. That stops a stranger with curl and nothing more. A player can read the token out of their own page and post whatever they like, and nothing short of running the game's rules on a server would change that. These boards are for bragging, and the site says so where people can read it rather than implying a rigour that is not there.

Guard the call so one run posts one score: a frame loop notices the end of a game sixty times a second.

Transforms

Transform2D is onejs-react's; oj's subclass only overrides makePoint, so point() returns oj's Vector2 instead of a CS one. Painter2D has no transform stack, so coordinates are transformed as they are recorded. t.path(painter) wraps only the ops that take coordinates; colours, widths, fill and stroke stay on the painter. Mirroring Painter's whole surface would mean editing the transform every time Painter grows a feature.

arc is never silently wrong. Under translation, rotation and uniform scale it is forwarded natively, so behaviour matches an untransformed arc exactly. Under non-uniform scale, skew or reflection it is flattened into cubic beziers, because a circle under those is an ellipse that Painter2D's Arc cannot express. arcTo is deliberately absent rather than approximated: calling it is a compile error, which beats a runtime surprise.

What a game can reach, which takes two mechanisms

Filtering the export surface. Anything in onejs-react whose public API requires building or receiving a C# object is left out of oj rather than shipped as a landmine. (A game may still name C# itself; see below.) onejs-react's Transform2D was the sharp one: its point() returns new CS.UnityEngine.Vector2, so oj exports a subclass whose points are oj's Vector2. The full list with reasons is the header comment in src/index.ts, and surface.test.ts enforces it, so a future export * from "onejs-react" fails the suite instead of silently reintroducing the landmines.

Shadowing the globals, which is the half that is easy to forget. Filtering exports does nothing about global scope: after the bootstrap runs, the bridge's plumbing (__cs, __releaseHandle, __registerCallback), the filesystem (readTextFile, writeTextFile, deleteFile) and the runtime's internals are sitting on the embedding page's globalThis. So the container evaluates a bundle through evaluateBundle in sandbox.ts, which runs it inside a function whose parameters shadow them (SHADOWED_GLOBALS), plus every browser-only global (document, window, AudioContext, XMLHttpRequest, ...), because a game that reaches for one can never leave the web (BROWSER_ONLY_GLOBALS).

CS, useExtensions and $typeof are deliberately not shadowed: a game may name C# directly, since oj cannot wrap the long tail of UnityEngine and the BCL. The price is a promise: a published game is pinned to its runtime version, so whatever link.xml preserves is that version's permanent API, and a runtime version may only ever add to it (PlayRuntime/README.md, Tools/link-surface-check.mjs).

That shadowing only works because oj is an esbuild external the container preloads, not a dependency bundled into each game. The reconciler calls CS at runtime, so if it shared a bundle with game code, any shadow that hid CS from the game would break the reconciler too. Externals are a prerequisite, not a size optimisation, though they also cut a game bundle from a couple of hundred kilobytes to a handful and make the runtime version pin mean something.

compileStyleSheet is injected rather than shadowed, and that is not optional: onejs-unity's uss-modules and tailwind plugins both emit a bare call to it into every bundle that uses CSS Modules or Tailwind.

Both mechanisms are compatibility and ergonomics decisions, not security ones, and the shadowing is a strong default rather than a jail: globalThis.CS walks straight past it. The iframe sandbox and the CSP on the game origin are what keep the platform safe. These keep it changeable, and a game that deliberately tunnels to globalThis.CS is out of contract and free to break.

Writing a game

import { useState } from "react"
import { View, Text, mount, useFrame, input } from "oj"

function Game() {
    const [jumps, setJumps] = useState(0)
    useFrame(() => { if (input.keyboard.wasKeyPressed("Space")) setJumps(jumps + 1) }, [])
    return <View><Text>{`jumps: ${jumps}`}</Text></View>
}

mount(<Game />)

No build config and no root plumbing: mount() knows where to render because the container told the runtime. examples/ holds complete games written this way, and they typecheck against oj exactly as a published game does:

| Example | Bundled | Exercises | |---|---:|---| | starter | 2.1 KB | What /new scaffolds: one screen, one loop, nothing else | | pendulum | 1.8 KB | A frame loop, and dt as the whole reason it runs the same everywhere | | tally | 3.0 KB | One reducer: every change is a named action, so undo is the log walked back | | one-note | 2.8 KB | One clip, loaded once, played at five pitches | | first-shader | 5.7 KB | A shader file (ripple.sl) and the one uniform a slider writes | | arcane-portal | 11.9 KB | A shader written in Magerie, unchanged, with a colour and a speed the player drives | | wordie | 82.6 KB | Turn-based input, CSS Modules, a seeded daily word | | falling-blocks | 9.8 KB | Real-time gravity and key repeat off the frame delta | | twos-company | 9.2 KB | USS transitions animating a board, stable ids across a move | | fireworks | 3.7 KB | Particles, and sounds shipped as the game's own files | | space-junk | 8.9 KB | useDrawing drawing a whole arcade game in one path, every frame | | murmuration | 5.1 KB | A spatial grid, and a simulation that has to stay order-independent | | wayfinder | 8.0 KB | Retained-mode elements where almost nothing changes per frame | | drop-everything | 4.5 KB | The physics world, and a pool because bodies cannot be added | | particle-lab | 11.4 KB | Sliders driving a real config, printed back out to paste | | solitaire | 11.6 KB | Drag and drop, suits drawn as paths, no pointer handlers at all | | big-fish | 8.3 KB | A room, a leaderboard, and a relay you cannot trust | | block-party | 14.5 KB | The same well as falling-blocks, drawn as one path because a room holds 24 of them | | squiggle | 12.2 KB | A field every client lays from one seed, so a join needs no handshake | | sumo | 12.2 KB | A physics world and a room at once, and a shove nobody can be told they took | | quickdraw | 10.0 KB | A reaction measured with no clock to share, and a board that sorts the wrong way | | fire | 1.5 KB | Tinder: a fire from two noise fields, a mask and a ramp, in fx | | ember | 10.8 KB | A fire written as a shader file, with the file on screen beside it | | tuner | 9.0 KB | Three uniforms, the shader that reads them, and its TypeScript source side by side | | foobar | 2.1 KB | A test bed for the asset path |

Every one typechecks against oj exactly as a published game does, the logic in each is tested without a screen, and npm test also runs ojplay test on every one in the real container (cli/examples.e2e.test.ts): its playtest.mjs when it has one, otherwise a smoke run that boots it and fails on a console error or a row that looks wrong. Three examples failed that for months with nothing running them (#3).

The four after starter are deliberately the smallest thing that teaches one idea, and nothing else: a frame loop, a reducer, a sound, a shader. They are where to send somebody who has not written one of these before, and each is short enough to read in full before the page finishes loading.

Four of them are worth reading for a decision rather than a mechanic. wayfinder uses elements where the arcade games use a painter, because a search changes four squares a frame and leaves a thousand alone. drop-everything creates every body it will ever have up front, because a physics world cannot grow. solitaire reads the pointer through input in a frame loop rather than through React's pointer events, for the reason in the input section above, and gets touch for free. big-fish is the one to read before writing anything multiplayer.

Input events queue to the frame boundary. A browser delivers a keydown whenever it likes, including between frames. Applying it on arrival stamps it with the frame that is already ending, so game logic reads it as last frame's press and wasKeyPressed is false. beginFrame drains the queue first, so a frame sees exactly the events that arrived since the previous one.

Before an example ships:

  1. ojplay typecheck passes.
  2. A playtest.mjs drives every control and checks what it changes, not the text beside it (examples/arcane-portal/playtest.mjs).
  3. ojplay test playtest.mjs passes. That also fails on a console error, on a centred row whose controls do not line up with their labels, and on a control within 8px of its neighbour. npm test runs it for every example.
  4. A value that changes (a slider's readout, a score) sits in a fixed-width box, right-aligned with tabular digits, 12px from the control it reports, so it neither jitters nor touches the control (examples/arcane-portal).
  5. Once it is live, node Tools/playtest/rows.mjs <sid> runs the same row checks on the published page.

The command line

ojplay is this package's bin: npm install -g ojplay once, then ojplay <command> anywhere. npx ojplay <command> needs no install, and is what agents are told. Inside a cart with its own ojplay in node_modules, the global ojplay runs that copy, so the version that builds the cart is the one it chose; once a day at most, a global ojplay at a terminal says in one line when npm has a newer one.

A cart's repository is two files, index.tsx and oj.json, because the site builds it and the editor's tree should be the cart and nothing else; what a terminal needs is written by init and gitignored like node_modules:

ojplay add @singtaa/lightning   # use another cart: oj.json gets its newest version, .oj/carts its source and art
                             #   (in an empty folder, starts a cart that uses it; no account needed)
ojplay update          # the carts this one uses, to their newest in the same major (--major: newest of all)
ojplay remove @singtaa/lightning   # stop using it; names the files that still import it
ojplay init            # package.json, tsconfig.json, env.d.ts, ignore rules (.git/info/exclude in a clone); then npm install
ojplay init --unity    # in Assets/<Name>/~ of a Unity project: a JSRunner project and prefab, installed and built
ojplay build           # bundle the cart the way the site does; errors as file:line:col
ojplay typecheck       # tsc --noEmit
ojplay run             # the cart in the site's real container, in a local headless Chrome
ojplay test playtest.mjs   # run, then drive the cart from a script (no script: a smoke run)
ojplay status          # what the site is running and why the tip differs (--json for scripts)
ojplay list            # every cart on the account, private ones included
ojplay login           # print a play.onejs.com link; after Allow there, this machine can push
ojplay logout          # forget that login, here and on the site
ojplay push            # git push, then exit 1 if the tip did not build
ojplay new "Name"       # create a cart on the site and clone it
ojplay runtime         # fetch the container into ~/.onejs-play (--runtime <version>)

init --unity makes a clone a JSRunner project in place. Clone the cart to Assets/<Name>/~ in a Unity project that has OneJS (Unity ignores a folder named ~, which keeps the source and node_modules out of the import), then run ojplay init --unity there. It writes JSRunner's own default files into the clone, read from the OneJS the project installed rather than copied into this package, points the build at the entry oj.json names, and puts a PanelSettings and <Name>.prefab beside the clone. Every file it writes into the clone goes in the repository's info/exclude, so git status still shows the game and nothing else. Then it installs and builds. Dragging the prefab into a scene runs the game, and JSRunner rebuilds on save from then on. A clone added with git submodule add works the same way: its exclude file is asked of git rather than assumed to be .git/info/exclude. The template table in cli/unity.mjs mirrors templateMapping in OneJS's JSRunner.cs, and the container's test holds the two together.

The cart's files sit at their own names in the repository, and OneJS reads a JSRunner project's files from ~/assets/. So the build init --unity writes carries assetsPlugin() (ojplay/unity), which copies every file the site would serve into assets/ before each build, keeping its folders. The copies are excluded from git, the site refuses a top-level assets folder so none of the cart's files can be in the way, and a copy is removed once its file leaves the cart (only a copy the plugin wrote, never a file put there by hand).

ojplay run runs what ships. It fetches the container the site serves at /runtime/<version>/ (the pin from /api/version, or --runtime) into ~/.onejs-play/runtime/<version>/ once, serves it with the cart's bundle and assets from a local origin, and boots it in Chrome the way the sandbox document does: __ojPlay.load(source, manifest). A desktop build of the container was considered and rejected: it would be a second runtime, on QuickJS rather than V8, and the bugs that matter (the 1.0.12 Task that never settled) were WebGL-only. Headless by default; --headed --watch opens a window and swaps a fresh build in on every save without reloading the runtime, which is the container's own hot path and takes about ten milliseconds. It exits 1 if the cart logged a console error, Property not found included.

ojplay test hands a script the running game. The script's default export gets a Game: read() (the text on screen, top to bottom), click(x, y), drag(), move() in stage pixels (page CSS pixels), press("KeyA"), hold("KeyA") (returns a release function), type("crane"), wait(ms), until(predicate), stage(), eval(js) in the page, shot(file), reload(), rowProblems(), errors and console. A thrown error fails the run, and so does a console error or a row that looks wrong (cli/rows.mjs, measured from resolved layout): in a centred row, a slider's track, a toggle's box or a text field's input off the row's centre line; in any row, a control within 8px of its neighbour, measured from a text's ink rather than its box. When the script throws, a screenshot lands in .oj/failed.png; call shot(file) for one otherwise. examples/wordie/playtest.mjs is the one to copy from. With no script, ojplay test lets the cart run --for seconds (default 2) and applies the same checks.

A pressed key is its own press. press, hold and type wait for the container to run a frame after each key goes down and after it comes up. A frame knows which keys went down since the last one but not in what order, so two presses that share a frame reach a game's loop in whatever order it checks them. A player never lands two keystrokes in one frame; a script can, and right after start the container's first frame lasts seconds: Wordie's playtest typed CRANE into it and submitted ACENR (#3). Keys go down the way a real keystroke does, keyDown with its text (Enter types \r) or rawKeyDown when it types nothing, and the page is the focused, visible tab: headless Chrome otherwise opens it unfocused and hides it at the first key, and a hidden page runs no frames. cli/fixtures/keys presses every named key straight after start and requires each to arrive alone and in order.

What the harnesses in Tools/playtest learned applies here unchanged, and their README's section on instruments that report clean answers while measuring nothing is the thing to read before writing a check: count letters rather than test membership, compare an identity or a movement rather than a constant, and open the screenshot.

cli/chrome.mjs is also what the production harnesses in Tools/playtest launch and speak to, so the launcher and the protocol client exist once. Chrome is found in the usual places or named by OJ_CHROME, starts with a mock keychain and a basic password store so it never asks the OS for its cookie key (on macOS with no keychain under HOME that was a dialog on the person's screen, and Page.navigate waited on it), and is driven over Node's built-in WebSocket, so ojplay run and ojplay test need Node 22 or newer, and say so when started on older Node. The package declares no engines: every new OneJS project installs it, and most never run those two. One container fills about four cores under the software rasteriser, so run one per four cores, and one at a time on Windows, where two at once ran past a 12 minute cap and four starved a four-core machine outright (key presses wait for frames, so a slow machine only makes a run longer). The first Chrome after a reboot can take half a minute to start; oj allows it 90 s. Ctrl-C during ojplay test closes its browser before it exits, and a run that fails at any step, Chrome's start and the first page load included, closes it and deletes the profile it made. ojplay login is login by link: the person opens the link it prints, signed in, and presses Allow, and the token (an agent login: create, edit, push and rebuild, main by fast forward only, 30 days) lands in ~/.onejs-play/token, or .oj/token in the cart where home cannot be written, with git's credential helper for the site pointed at it. --no-wait prints the link and exits; ojplay login --wait <code> collects that one, and --wait alone collects the only one waiting. OJ_TOKEN, a token from the site's tokens page, is used instead when set. OJ_SITE points every command at another origin; OJ_HOME moves the cache. push and status find the cart from the clone's origin: a /c/<sid>.git URL names it (and an older /g/<sid>.git one), and the address bar's /@handle/name.git is looked up in the account's own carts, so that form needs the login.

Testing

npm test          # vitest, including ojplay test over every example (needs Chrome and the network)
npm run typecheck # tsc --noEmit

npm test runs ojplay test on every example and CLI fixture, in the CLI's own headless Chrome against the runtime the site says is live (fetched once into ~/.onejs-play). One Chrome is started first, so a cold machine's slow first start is not charged to a cart. Runs go one per four cores (one on a four-core CI runner, always one on Windows; OJ_SWEEP_LIMIT overrides it), and a run with no result after three minutes is interrupted and fails with what it printed. The sweep ends by printing Chrome's start times (median, p90, worst, against the limit) and the run lengths. About two and a half minutes on a four-core runner, five on Windows; npx vitest run --exclude cli/examples.e2e.test.ts skips it while iterating on something else.

pre-setup.ts installs a permissive CS stub, because onejs-react's components.tsx calls useExtensions(CS.UnityEngine.ImageConversion) at module scope. The real container has QuickJSBootstrap installed long before a game bundle is evaluated, which is also a constraint on the host's setCode hot swap: the bootstrap globals have to survive the soft reset, not just the initial load.

color.test.ts runs a cross-package parity check against onejs-react's toWire, so Color and particles keep reading colours the same way.

Gotchas

Vectors are references, not structs. UnityEngine.Vector2 is a struct, so a = b copies. These are JS classes, so a = b aliases and mutating one mutates the other. Use clone() where C# would have copied for you.

Mathf is faithful, including the surprising parts. Mathf.Sign(0) is 1, not 0. Mathf.Round is banker's rounding, so Mathf.Round(0.5) is 0 and Mathf.Round(2.5) is 2, unlike Math.round.

The stage's y counts down, and the keyboard's axes count up. Positions (the mouse, touches, styles) are y-down screen space, while wasd(), arrows() and axis2D() give y as +1 for up, as Unity does. Subtract the axis to move up the screen: y -= input.keyboard.wasd().y * speed * dt.

Key names are Unity's (W, Space, LeftArrow), and DOM KeyboardEvent.code values (KeyW, ArrowLeft) work too, on the site and in Unity. Both name the physical key, so WASD stays the same three-key row on AZERTY. A name that is neither warns once rather than staying silently up.

random ranges are max-exclusive for both ints and floats, unlike UnityEngine.Random, which is exclusive for ints and inclusive for floats.

Follow-ups

  • Have onejs-react capture CS at module scope, so the container can delete the globals outright instead of only shadowing them.
  • Gamepad, via a browser Gamepad API adapter pushing into InputSink.
  • Axis smoothing, as an option on the axis binding rather than a second method.
  • oj.storage. oj.audio, assetUrl, useFrame and the oj namespace object were on this list and are all shipped.
  • Names on peers. A room reports numbers, and a game that wants "Sam" has to invent its own naming. Held back because a name people choose is a moderation surface, not because it is hard.