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 containermount() 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.positionis in Unity screen coordinates. - Key names are DOM
KeyboardEvent.codevalues, not UnityKeyCodenames. 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)is1, not0, andMathf.Roundis banker's rounding, soMathf.Round(0.5)is0andMathf.Round(2.5)is2.Math.rounddiffers on both counts.Vector2is a class, not a struct.a = baliases where C# would have copied, so mutating one mutates the other. Useclone().- 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.
randomranges are max-exclusive for both integers and floats.UnityEngine.Randomis 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.