Writing a Game

The entry point, the frame loop, the manifest, and what oj adds

A game 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. Nothing about it is Unity specific, so any React you already know 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 />)

This page is the entry point, the loop and the manifest. What you actually draw with, and how styling and events work, is Elements and Styling.

mount()

mount(<Game />) is the entry point, in place of render(element, __root). The container already knows the root and the stage, so a game does not plumb either through its tree.

mount(<Game />)
mount(<Game />, { theme: false })            // skip the built-in control theme
mount(<Game />, { stage: { size: [800, 600] } })  // only read outside the container

mount() also applies a small stylesheet to 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 game. It touches only those controls, never your own Views and Texts, an inline style beats it, and { theme: false } turns it off. It is compiled by mount() rather than applied by the site, so an ejected game keeps the same look.

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 game 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 a single slow frame would break your simulation. A backgrounded tab can hand you a dt of several seconds, which is enough for a fast-moving object to pass straight 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 or drawing with the batched painter does not. A useful split is to drive motion through refs in useFrame and let React handle the parts that change a few times a second, like a score or a menu.

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 stage units, matching the coordinates you laid your game out in. That is different from a normal OneJS project, where 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 physical three keys on an AZERTY keyboard.

Prefer input over React's pointer events for anything positional. Pointer events report panel pixels, input reports stage units, and on a letterboxed stage those differ by the size of the bars, so hit testing against your layout silently misses. Reading through input also gets you touch for free.

The manifest

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

{
    "schema": 1,
    "runtime": "1.0.0",
    "name": "Sumo",
    "author": "singtaa",
    "description": "Everyone is a blob on a platform that keeps getting smaller.",
    "entry": "index.tsx",
    "stage": { "size": [960, 640], "fit": "letterbox" },
    "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 game ships
name What the game is called. Renaming does not change its URL, which is the game's id
stage Logical size and how it is fitted. See The Stage
description One or two sentences, shown on the game's card and page
tags, controls How the catalog files and filters it
cover How the card is recorded. See Publishing
author Your handle. Not read by the site

Every one of those applies when you publish, so editing the manifest and pressing Publish is how you rename a game, resize its stage, or change how the catalog files it.

A field you leave out is left alone, not reset. A manifest that never mentions stage does not move the stage to a default; it says nothing about the stage. That is what makes it safe to keep a short manifest.

The Publish panel shows you what will change before it applies, under "oj.json also changes", with the old value and the new one for each field. These are easy to alter by accident while editing something else, so nothing here changes without being shown first.

runtime is the exception: it is never read. Publishing puts your game on whatever container is current, and it records which one, so a game you leave alone keeps the runtime it was published against no matter how many containers ship afterwards. Nothing changes under a game that is sitting still.

That is the other way round from the rest of the manifest, and it is deliberate. oj lives in the container rather than in your bundle, so a game frozen at the container it was first created on would have this week's code running against a runtime from months ago, without the functions you just wrote against. Moving forward when you publish is what keeps the two halves the same age.

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 the transform stack Painter2D lacks
useParticles The 2D particle system
usePhysics A 2D physics world, simulated in C# and pumped for you each frame
audio Sound over Unity's audio, not WebAudio, so it survives an eject
sl, ShaderProgram Per pixel effects you write yourself
useTexture, assetUrl Your own files
useRoom, scores Other people and leaderboards

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 instead. A world also cannot grow, so create every body you will need up front and pool them.

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

Game rules are ordinary functions and can be tested without a screen. The examples that ship with the site all separate them: ocean.ts holds the rules of Big Fish and index.tsx draws them, so the rules have unit tests and the drawing does not need any.

That split is worth copying for anything with rules worth getting right. It is also what makes a game readable to somebody who forks it.