Opening a Game in Unity
What the download contains, and why the source does not change
Most games have an Eject button, and it is the way out of the sandbox rather than an export feature. An author who ships art they did not make can turn copying off, which takes the button away for everybody but them: see No fork.
Play runs your game inside a shared container, which is what makes a game a few kilobytes and a fork instant. The price is the container's limits: no C#, no scenes, no filesystem. When a game outgrows them, this is the door, and what comes through it is a Unity folder that runs the moment you drop it in a scene.
The promise is that your game file does not change. Not a port, not a rewrite: the same source, running in a project you own.
If you have never used Unity, that is the thing to know before you click: past this point you are using OneJS itself, and onejs.com is its documentation.
Three steps
- Install OneJS in a Unity project (6.3 or newer) from the Package Manager:
https://github.com/Singtaa/OneJS.git - Unzip the download inside that project's
Assets/folder. - Drag the prefab into a scene.
That is the game running. It arrives already built, so there is no npm install and nothing to compile between the download and the first frame.
To change it, the source is in the ~/ folder that came with it:
cd Assets/OneJSPlay/<YourGame>/~
npm install
npm run watchnpm run watch rebuilds on save and the running game reloads itself, without leaving the Game view. Each download carries its own README with these steps and the stage line for that particular game.
What is in the archive
The download is one folder, laid out as a JSRunner project:
| Path | What it is |
|---|---|
<YourGame>.prefab |
A GameObject with a JSRunner. This is the thing you drag into a scene. |
PanelSettings.asset |
Marks the folder as the project. JSRunner runs the bundle beside it. |
app.js.txt |
Your game, already built. |
~/ |
Your source, your assets, a JSRunner scaffold, and oj/ vendored |
Unity ignores a folder named ~. That is not a trick, it is the convention JSRunner already uses for its working directory, and it is why the source and your assets can sit inside Assets/ without being imported, compiled or shipped in a build. It is also why this download is a zip: a .unitypackage is built out of things Unity imported, so it could never have carried them.
Your assets keep the names your source loads them by, so nothing needs editing to run. The scaffold comes from OneJS's own project templates rather than being written for the download, so it cannot drift from what the editor creates when you start a project by hand.
oj is vendored rather than depended on: a copy of the code that ran, sitting in your project. That is what an eject means. It keeps building whether or not the package is ever published, and it cannot change under you later. Your game imports from "oj" and a tsconfig paths entry resolves it, which esbuild reads, so the build config ships byte for byte from the template.
Files the scaffold owns (package.json, tsconfig.json, esbuild.config.mjs, .gitignore, README.md) come from the scaffold even if your game shipped a file with that name. A game cannot supply a package.json that replaces the one making the project build.
Why the source does not change
oj is a strict subset of OneJS. Everything a game can reach also exists in a normal Unity project, so there is nothing to port.
The parts that only make sense on the site degrade rather than break:
| After an eject | |
|---|---|
assetUrl |
Resolves to the project's assets/ folder, or StreamingAssets in a build |
useRoom |
connected stays false, send() does nothing, no handler fires |
scores |
available is false, submit resolves to null, top returns [] |
mount() |
Starts a runtime itself, since there is no container to provide one |
| The control theme | Travels with you: it is compiled by mount(), not applied by the site |
A multiplayer game therefore still runs after an eject. It just runs alone, which is the correct behaviour for a game whose server was a site you are no longer on.
mount() reads the stage from the manifest inside the container. There is no manifest outside one, so pass it:
mount(<Game />, { stage: { size: [960, 540], fit: "letterbox" } })The container ignores that argument, so you can add it before you eject and it changes nothing.
What you get that you did not have
The restrictions in the container are the container's, not OneJS's. Once the game is a project:
- C# is reachable.
CS.*, your own types, Unity's whole API surface. - Scenes, cameras and 3D. The game was a UI panel; now it can sit in front of anything.
- Every build target. Desktop, mobile, console, XR, and WebGL if you want the web without the site.
- No sandbox. Files, network, native plugins.
Nothing forces you to use any of it. A game that stays exactly as it was will keep building.
One thing to check first
input reads through Unity's real input bridge in a project rather than through the container's browser-event backend, and the two report pointer positions in different coordinate spaces. The runtime converts for you, so a pointer-driven game plays the same after an eject.
That conversion is unit tested but is worth a quick look in your own project, because it is the one part of the promise that depends on your setup rather than on arithmetic. Move the pointer, confirm the game follows it. Keyboard and gamepad were never in a coordinate space and pass through untouched.
If you use Unity Input System actions, note that a position read out of an action does not go through that conversion and arrives in screen space. Direct input.mouse and input.touches reads are converted; actions are not.