The Stage

Your game's logical size, and how it maps onto the player's window

You lay a game out once, in whatever size suits it. The player's window is some other size. The stage is what maps between them.

"stage": { "size": [960, 540], "fit": "letterbox" }

Now every coordinate in your game is in those 960 by 540 units, whatever the window is actually doing. Pointer positions arrive in the same units, so a layout and a hit test agree without any conversion.

960 by 540 letterboxed is the default. It is only a default.

Change it whenever you like: edit stage in oj.json and publish. The Publish panel shows the change before it applies, and a manifest that does not mention the stage leaves it as it is. See the manifest.

Choosing a fit

fit What happens to the difference
letterbox Aspect preserved, bars fill the remainder. The default
cover Aspect preserved, the overflow is cropped
stretch Aspect ignored, the stage fills exactly
fluid No fixed stage. The stage tracks the viewport in logical pixels

letterbox is right for a game with a designed screen: a board, a puzzle, an arcade playfield. Nothing moves, nothing is lost, and there are bars.

cover suits a game whose edges are scenery rather than content. Nothing is distorted and nothing is bordered, but a player on an unusual aspect ratio sees less than you drew, so keep anything that matters away from the edges. layout.visible tells you which part of the stage is actually on screen.

stretch distorts. It is occasionally what a background or a purely abstract visual wants, and almost never what a game with sprites wants.

fluid has no fixed stage at all: the stage is the viewport, in logical pixels, and useStage() re-renders when it changes. This matters more than it looks. UI Toolkit is the renderer, so a good share of games here are really responsive apps (card games, incrementals, builders, anything with a list), and those want to reflow rather than scale. If your layout is built from flexbox and percentages rather than absolute coordinates, fluid is probably the right answer.

Reading the layout

import { useStage } from "oj"

function Game() {
    const stage = useStage()
    // stage.width, stage.height        logical size to draw into
    // stage.visible                    the part on screen (the whole stage unless cropped)
    // stage.scale, scaleX, scaleY      pixels per logical unit
    // stage.offsetX, offsetY           where the stage origin sits, in viewport pixels
    // stage.viewportWidth, viewportHeight
    // stage.fit, stage.matte
}

Under fluid, width and height are the ones to watch: they change as the window does, and the hook re-renders when they do.

scale is a conservative uniform value, useful for picking a font size that stays crisp. Under stretch the two axes differ, so use scaleX and scaleY there.

Other stage options

"stage": { "size": [320, 180], "fit": "letterbox", "pixelPerfect": true, "matte": "#0b0d10" }

pixelPerfect snaps the scale to a whole number so texel grids stay aligned, which is what pixel art needs to avoid shimmering. It floors for letterbox (never below 1) and ceils for cover, so coverage is preserved. It is ignored for stretch and fluid, neither of which has a single uniform scale.

matte is what fills the space around a letterboxed stage. It defaults to a dark neutral that sits behind anything without competing with it.

The matte is painted by the UI rather than cleared on the camera or the panel, and that is deliberate: both of those write the value straight into the framebuffer without the colour conversion the UI pipeline applies, which in a linear-colour project turns #14181d into #4f565f. If you have ever set a clear colour and got a visibly lighter one, that is why.

Fullscreen

Fullscreen is orthogonal to fit. It changes how many pixels are available; whichever fit you chose still applies to them. A letterboxed game gets a bigger stage and thinner bars, a fluid game gets a bigger stage.

The page around the game owns the fullscreen request, because the browser requires a user gesture and the permission lives on that side of the frame boundary.

The trap

Pointer events and input do not report the same numbers.

input.mouse.position and input.touches[n].position are in stage units. React's onPointerDown and friends carry panel pixels. On a letterboxed stage those differ by the size of the bars, and nothing warns you: hit testing against a layout written in stage units simply misses, by more the further the window is from your aspect ratio.

Read the pointer through input, inside your frame loop, and the question does not arise. You also get touch for free, since the same code sees input.touches.

If you do use a pointer event, do it for what pointer events are good at (a button that reacts to being clicked, a hover state) and keep positional logic on the input side.

A note on cropping

Whether a cover stage is actually cropping is decided against a small pixel tolerance rather than exactly. Floating point error in viewport - size * scale makes an exactly-fitting stage look a hair cropped, which would otherwise report every game as cropped on at least one axis. If you are branching on stage.visible, compare against the stage size rather than testing for a non-zero offset.