Writing a Sketch

The entry point, the frame loop, and the manifest

A sketch 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 } from "oj"

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

    useFrame((dt) => {
        x.current += input.keyboard.wasd().x * 300 * dt
        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 sketch 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 sketch. It touches only those controls, an inline style beats it, and { theme: false } turns it off. It travels with an eject, 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 sketch 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 DOM KeyboardEvent.code values, not Unity KeyCode names. They are layout independent, so WASD stays the same three physical keys on an AZERTY keyboard.

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 sketch:

{
    "schema": 1,
    "name": "Sumo",
    "author": "singtaa",
    "description": "Everyone is a blob on a platform that keeps getting smaller.",
    "entry": "index.tsx",
    "tags": ["multiplayer", "physics", "arcade"],
    "controls": ["keyboard", "pointer", "touch"]
}
Field
entry The file to build from. Defaults to index.tsx, and has to name a file this sketch ships
name What the sketch is called. Renaming does not change its URL
description One or two sentences, shown on the sketch's card and page
tags, controls How the catalog files and filters it
author Your handle. Not read by the site

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

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 sketch gets the whole window.

There is one more field, runtime, which you do not set and the site does not read. Publishing puts your sketch on the current container and records which one, so a sketch 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 Seeded generators, for daily challenges, replays and reproducible bugs
Transform2D, Painter Vector drawing, with a transform stack
usePhysics A 2D physics world, simulated in C# and pumped for you
audio Sound over Unity's audio, not WebAudio, so it survives an eject
Code Syntax highlighted source, for a sketch that shows its own
useParticles, fx, sl Effects
useTexture, assetUrl Your own files
useRoom, scores Rooms for several people in one sketch, and a leaderboard per sketch

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 sketches need, but the long tail of Unity and the BCL is not reachable any other way, so a sketch 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 sketch is pinned to the runtime it was built against and that set only ever grows, so a sketch 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 sketch 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

Sketch 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 sketch readable to somebody who forks it.