Writing a Cart

The entry point, the frame loop, and the manifest

A cart is one React tree and one frame loop. Everything else on this page is detail.

If you have never written React, that is the thing to learn first. Components, JSX and hooks are assumed everywhere here, and react.dev/learn is the place to start. None of it is Unity specific, so any React you know already applies.

import { useRef } from "react"
import { View, Text, mount, useFrame, input, type VisualElement } from "oj"

function Game() {
    const x = useRef(100)
    const ref = useRef<VisualElement>(null)

    useFrame((dt) => {
        x.current += input.keyboard.wasd().x * 300 * dt
        if (ref.current) ref.current.style.left = x.current
    })

    return (
        <View style={{ flexGrow: 1, backgroundColor: "#14181d" }}>
            <View ref={ref} style={{ position: "absolute", top: 100, width: 40, height: 40, backgroundColor: "#ffd166" }} />
            <Text style={{ color: "white" }}>Move with A and D</Text>
        </View>
    )
}

mount(<Game />)

What you draw with, and how styling and events work, is Elements and Styling.

mount()

mount(<Game />) is the entry point. The container already knows the root and the stage, so a cart plumbs neither through its tree.

mount(<Game />)
mount(<Game />, { theme: false })   // skip the built-in control theme

mount() also styles the controls the runtime provides (Button, Slider, TextField and friends), because UI Toolkit's own runtime theme is built for the editor and looks out of place next to a cart. It touches only those controls, an inline style beats it, and { theme: false } turns it off. It comes with you into Unity, since mount() applies it rather than the site.

useFrame()

useFrame((dt) => {
    // dt is seconds since the last frame
})

The callback runs every frame for as long as the component is mounted. dt is what makes a cart run at the same speed on a 60 Hz laptop and a 144 Hz monitor, so multiply movement by it rather than assuming a fixed step.

Clamp it if one slow frame would break your simulation. A backgrounded tab can hand you a dt of several seconds, enough for a fast object to pass through a wall:

useFrame((dt) => {
    const step = Math.min(dt, 1 / 20)
    // ...
})

Keep per-frame work out of React. Setting state sixty times a second re-renders the tree sixty times a second; writing to ref.current.style does not. Drive motion through refs in useFrame, and let React handle what changes a few times a second, like a score or a menu.

The callback can read state and props directly. Each frame runs the callback from your latest render, so a comparison against a state value sees the current one. The optional second argument, a dependency list, only decides when the frame subscription is remade, and you will rarely need it.

Input

Read the player through input, the same module a normal OneJS project uses.

if (input.keyboard.wasKeyPressed("Space")) jump()
const move = input.keyboard.wasd()
const p = input.mouse.position
for (const touch of input.touches) aimAt(touch.position)

Two things are specific to Play:

  • Positions arrive in window pixels, the same numbers as useStage() and pointer events. In a normal OneJS project input.mouse.position is in Unity screen coordinates.
  • Key names are Unity's: "W", "Space", "LeftArrow", "Escape". They name the physical key rather than the letter printed on it, so WASD stays the same three keys on an AZERTY keyboard.
  • wasd() and arrows() give y as +1 for up, Unity's way round, while the stage counts y downward. Subtract it to move up the screen.

Read positions through input in a frame loop. The same code then sees the mouse and every touch, so you get touch for free. When a pointer event is the right tool, e.localX and e.localY are relative to the element the handler is on.

A swipe, by finger or mouse drag, is one hook:

import { useSwipe } from "oj"

useSwipe((direction) => push(direction))   // "left" | "right" | "up" | "down"

It fires once per gesture, after the pointer has travelled 28 pixels ({ threshold } changes that), and reads the same input your frame loop does, so a mouse and a finger need no separate code.

Layout that follows the stage

useStage() gives you the window size to lay out against, and the responsive hooks read that same size: useBreakpoint(), useScreenSize(), useResponsive() and useMediaQuery(). mount() provides them; there is nothing to wrap.

The manifest

oj.json sits beside your source and describes the cart:

{
    "name": "Sumo",
    "author": "singtaa",
    "description": "Everyone is a blob on a platform that keeps getting smaller.",
    "entry": "index.tsx",
    "tags": ["physics", "arcade"],
    "controls": ["keyboard", "pointer", "touch"]
}
Field
entry The file to build from. Defaults to index.tsx, and has to name a file this cart ships
name What the cart is called. Renaming changes its URL, and the old one redirects to the new
description One or two sentences, shown on the cart's card and page
tags, controls How the catalog files and filters it
author Your handle. Not read by the site
version This cart's version, like 1.2.0
exports The file other carts import from, when this cart is a piece for them
dependencies The carts this one uses, each at an exact version. ojplay add writes it
license The license for your code and your own files. MIT when you leave it out
credits Files that are not yours to license, and what the cart's Stats say about them

Every one of those applies on every push, so editing the manifest is how you rename a cart or change how the catalog files it.

Versions

Changing version is how you release. The first push that builds with a new number makes that build the version, and the console says Released 1.3.0. A released version never changes: a later push under the same number still runs, and the console tells you once that the version stays as it was. Nothing releases until you press Push, so a number typed early waits for the rest of the change.

Using another cart

A cart can use another one's pieces, or run a whole one. From a terminal, with ojplay installed once (npm install -g ojplay):

ojplay add @singtaa/lightning

That adds it to dependencies at its newest version, fetches its source and art, and prints how to import it: import { Bolt } from "@singtaa/lightning". A cart with no exports is a whole cart, and import "@singtaa/lightning" runs it. In an empty folder, ojplay add starts a new cart that uses it. ojplay update moves to newer versions, and ojplay remove takes one out. In the editor, the carts a cart uses are listed under its files, read only.

Files that are not yours

A license covers only what you own. Mark each file that belongs to somebody else under credits, and the cart's Stats, in its editor, list it beside the license. A key ending in / covers a folder:

{
    "credits": {
        "portraits/": "game art © Blizzard Entertainment"
    }
}

A field you leave out is left alone, not reset. A manifest that never mentions tags does not clear them; it says nothing about them. That is what makes a short manifest safe.

An older manifest may still carry a stage. It publishes as before, with a note that the field is not read: every cart gets the whole window.

An older cart may carry schema or runtime. Nothing reads either: publishing puts your cart on the current container and records which one, so a cart you leave alone keeps working.

What else oj gives you

Beyond the components you already know:

Mathf, Vector2, Color Unity shaped, implemented in plain JavaScript. No interop cost
random random.int(0, 10) straight away, or random("daily") for a seeded generator: daily challenges, replays and reproducible bugs
drawing, useDrawing, Painter, Transform2D Vector drawing, with a transform stack; useDrawing(ref, draw, "frame") redraws every frame
usePhysics A 2D physics world, simulated in C# and pumped for you
audio Sound over Unity's audio, not WebAudio, so it works in Unity too
Code Syntax highlighted source, for a cart that shows its own
useParticles, fx, sl Effects
useTexture, assetUrl Your own files

usePhysics reads its config once, on mount, because the simulation lives in C# and a re-render must not throw it away. Change a running world through its methods. A world also cannot grow, so create every body you need up front and pool them.

Naming C# directly

oj wraps what most carts need, but the long tail of Unity and the BCL is not reachable any other way, so a cart can name C# itself:

const v = new CS.UnityEngine.Vector3(0, 1, 0)

What you can reach is the set of namespaces the container preserves. A published cart is pinned to the runtime it was built against and that set only ever grows, so a cart that names something today keeps working. Something outside the set is not there at all, rather than being there and failing later.

Prefer oj's own Vector2, Color and Mathf where they cover what you need: they are plain JavaScript, so they cost nothing to cross.

Completions for CS come from a type package the editor fetches, so they can be missing when the cart itself runs perfectly well.

Things that behave differently from plain JavaScript

These follow Unity rather than the language, which is the point, but they surprise people:

  • Mathf.Sign(0) is 1, not 0, and Mathf.Round is banker's rounding, so Mathf.Round(0.5) is 0 and Mathf.Round(2.5) is 2. Math.round differs on both counts.
  • Vector2 is a class, not a struct. a = b aliases where C# would have copied, so mutating one mutates the other. Use clone().
  • Positive y is down. The stage is a screen space with its origin at the top left, so a key press that should move something up subtracts.
  • random ranges are max exclusive for both integers and floats. UnityEngine.Random is exclusive for integers and inclusive for floats.

Testing

Cart rules are ordinary functions and can be tested without a screen. Keeping them in their own file, with the drawing separate, means the rules get unit tests and the drawing needs none. It is also what makes a cart readable to somebody who forks it.

The cart as a whole is tested from a local clone. npx ojplay test runs it in the site's own container in a headless Chrome, and fails on a console error, an asset the site would not serve, or a row of controls out of line. Give it a script, npx ojplay test play.mjs, and the script's default export receives the running cart to drive: click(x, y), press("Space"), type("crane"), until(predicate), read() for the text on screen, and shot(file). A thrown error fails the run.