Web APIs#
OneJS provides browser-compatible APIs so JavaScript libraries and patterns work as expected. These are implemented using Unity's underlying systems.
localStorage#
Persist data using Unity's PlayerPrefs:
// Store data
localStorage.setItem("username", "player1")
localStorage.setItem("settings", JSON.stringify({ volume: 0.8 }))
// Retrieve data
const username = localStorage.getItem("username") // "player1"
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}")
// Remove data
localStorage.removeItem("username")
// Clear everything localStorage stored
localStorage.clear()
// Enumerate the stored keys
for (let i = 0; i < localStorage.length; i++) console.log(localStorage.key(i))On native platforms#
On WebGL, localStorage is the browser's own. On native platforms it is backed by PlayerPrefs:
- Keys are stored in PlayerPrefs under an
onejs:prefix, solocalStorage.setItem("volume", "1")writes the PlayerPrefs keyonejs:volume. C# code reading a value JavaScript stored adds the prefix.clear(),key()andlengthcover only these keys, soclear()never touches PlayerPrefs your C# code or plugins set. - A key stored by OneJS 3.9.4 or earlier, without the prefix, moves under it the first time it is read.
- Writes are saved to disk a second after the last one, and when the app stops or reloads, so a burst of
setItemcalls costs one save. - Data persists permanently (no expiration).
Example: Saving Game State#
function usePersistentState(key, defaultValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : defaultValue
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
function Settings() {
const [volume, setVolume] = usePersistentState("volume", 1.0)
return (
<Slider
value={volume}
min={0}
max={1}
onChange={(e) => setVolume(e.value)}
/>
)
}sessionStorage#
On native platforms sessionStorage is an alias to localStorage. Unity doesn't have browser-style sessions, so data persists across app restarts. On WebGL it is the browser's own sessionStorage, cleared when the tab closes.
If you need session-scoped data, clear on app start:
// At app initialization
const SESSION_KEY = "__session_id"
const currentSession = Date.now().toString()
const lastSession = localStorage.getItem(SESSION_KEY)
if (lastSession !== currentSession) {
// Clear session data from previous run
localStorage.removeItem("temp_data")
localStorage.setItem(SESSION_KEY, currentSession)
}URL & URLSearchParams#
Parse and manipulate URLs:
// Parse a URL
const url = new URL("https://api.example.com:8080/users?page=1&limit=10#section")
console.log(url.hostname) // "api.example.com"
console.log(url.port) // "8080"
console.log(url.pathname) // "/users"
console.log(url.search) // "?page=1&limit=10"
console.log(url.hash) // "#section"
console.log(url.origin) // "https://api.example.com:8080"
// Modify URL
url.pathname = "/posts"
url.searchParams.set("page", "2")
console.log(url.href) // "https://api.example.com:8080/posts?page=2&limit=10#section"URLSearchParams#
Work with query strings:
// Create from string
const params = new URLSearchParams("foo=1&bar=2")
// Get values
params.get("foo") // "1"
params.getAll("foo") // ["1"]
params.has("bar") // true
// Modify
params.set("foo", "new")
params.append("baz", "3")
params.delete("bar")
// Iterate
params.forEach((value, key) => {
console.log(`${key}: ${value}`)
})
// Convert to string
params.toString() // "foo=new&baz=3"API Request Helper#
function buildApiUrl(endpoint, params = {}) {
const url = new URL(endpoint, "https://api.example.com")
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, String(value))
})
return url.href
}
// Usage
const url = buildApiUrl("/users", { page: 1, limit: 20 })
// "https://api.example.com/users?page=1&limit=20"Base64 Encoding#
Encode and decode Base64:
// Encode string to Base64
const encoded = btoa("Hello, World!")
console.log(encoded) // "SGVsbG8sIFdvcmxkIQ=="
// Decode Base64 to string
const decoded = atob("SGVsbG8sIFdvcmxkIQ==")
console.log(decoded) // "Hello, World!"Use Cases#
// Encode JSON for URL-safe transmission
const data = { id: 123, name: "test" }
const encoded = btoa(JSON.stringify(data))
// Basic auth header
const credentials = btoa(`${username}:${password}`)
const headers = { Authorization: `Basic ${credentials}` }Timers#
Standard JavaScript timing functions:
// One-time delay
const timeoutId = setTimeout(() => {
console.log("Executed after 1 second")
}, 1000)
clearTimeout(timeoutId) // Cancel
// Repeated interval
const intervalId = setInterval(() => {
console.log("Every 500ms")
}, 500)
clearInterval(intervalId) // Stop
// Immediate execution (macrotask, as in Node)
const immediateId = setImmediate(() => {
console.log("Runs after the current code and its promises settle")
})
clearImmediate(immediateId) // Cancel
// Animation frame (synced with Unity Update)
function animate() {
// Update animation
requestAnimationFrame(animate)
}
const rafId = requestAnimationFrame(animate)
cancelAnimationFrame(rafId) // StopReact Timer Patterns#
// Interval hook
function useInterval(callback, delay) {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (delay === null) return
const id = setInterval(() => savedCallback.current(), delay)
return () => clearInterval(id)
}, [delay])
}
// Usage
function Clock() {
const [time, setTime] = useState(new Date())
useInterval(() => {
setTime(new Date())
}, 1000)
return <Label text={time.toLocaleTimeString()} />
}performance.now()#
A timestamp in milliseconds:
const start = performance.now()
// Later, in another frame
console.log(`Took ${performance.now() - start}ms`)Note: On WebGL this is the browser's high-resolution clock. On native platforms it is the current frame's timestamp, so it does not advance inside synchronous code: use it to measure across frames, and use Date.now() or CS.UnityEngine.Time.realtimeSinceStartupAsDouble to time a block of work.
queueMicrotask#
Schedule code to run after current task but before rendering:
queueMicrotask(() => {
console.log("Runs after current synchronous code")
})
console.log("This logs first")Useful for batching updates:
let pendingUpdates: (() => void)[] = []
let scheduled = false
function scheduleUpdate(update: () => void) {
pendingUpdates.push(update)
if (!scheduled) {
scheduled = true
queueMicrotask(() => {
const updates = pendingUpdates
pendingUpdates = []
scheduled = false
// Process all batched updates
updates.forEach(u => u())
})
}
}Files#
onejs.fs reads and writes text files by absolute path, and onejs.paths gives you the folders to build those paths from. Use them for save files, settings, mods and themes a player adds. Small key-value data is simpler in localStorage, and assets you ship with the game load through Asset Loading.
type Settings = { volume: number }
const settingsFile = `${onejs.paths.persistentData}/settings.json`
async function loadSettings(): Promise<Settings> {
if (!onejs.fs.exists(settingsFile)) return { volume: 1 }
return JSON.parse(await onejs.fs.readText(settingsFile))
}
async function saveSettings(settings: Settings) {
await onejs.fs.writeText(settingsFile, JSON.stringify(settings))
}
const themes = onejs.fs.list(`${onejs.paths.persistentData}/themes`, "*.uss")| Member | What it does |
|---|---|
onejs.fs.readText(path) | Resolves to the file's text; rejects when there is no such file |
onejs.fs.writeText(path, text) | Writes the file, creating it and any missing folders, and replaces what was there |
onejs.fs.exists(path) | Whether the file exists |
onejs.fs.directoryExists(path) | Whether the folder exists |
onejs.fs.delete(path) | Deletes the file; returns false when there was nothing to delete |
onejs.fs.list(path, pattern?, recursive?) | Full paths of the files in a folder matching pattern (default "*"); an empty array when the folder does not exist |
onejs.paths.persistentData | Application.persistentDataPath: writable, and kept between sessions |
onejs.paths.working | The app's working directory: ~/ in the Editor, persistentData in a build |
onejs.paths.streamingAssets | Application.streamingAssetsPath |
onejs.paths.data | Application.dataPath |
onejs.paths.temporaryCache | Application.temporaryCachePath |
On Android and WebGL, onejs.paths.streamingAssets is a URL rather than a folder, so onejs.fs cannot read it there. Load those files with fetch or the async loaders in Asset Loading.
These names arrived in OneJS 3.9.5. Before that, and still today, the same functions are the globals readTextFile, writeTextFile, fileExists, directoryExists, deleteFile and listFiles, and the folders are __persistentDataPath, __workingDir, __streamingAssetsPath, __dataPath and __temporaryCachePath.
StyleSheet APIs#
See Styling: StyleSheet API for runtime stylesheet management (loadStyleSheet, compileStyleSheet, removeStyleSheet, clearStyleSheets, also under onejs.styles from OneJS 3.9.5).
The onejs Namespace#
From OneJS 3.9.5 every public OneJS global is also under one onejs object. The older names keep working and mean the same thing.
onejs member | Older global | Where it is covered |
|---|---|---|
onejs.isPlaying | __isPlaying | Lifecycle Hooks |
onejs.root | __root | The root element you render() into, as in the Quickstart |
onejs.paths, onejs.fs | __persistentDataPath, readTextFile and the rest | Files above |
onejs.styles | loadStyleSheet, compileStyleSheet, removeStyleSheet, clearStyleSheets | Styling: StyleSheet API |
onejs.cs.typeOf, onejs.cs.extensions, onejs.cs.release | $typeof, useExtensions, releaseObject | C# Interop: Extension Methods |
onejs.cs.typeExists | None: new in 3.9.5 | C# Interop: Checking a Type Exists |
TypeScript knows it through onejs-unity/globals in your tsconfig's types, which projects made by OneJS 3.9.5 have. Project Setup shows how to add it to an older project.
Browser Detection in Libraries#
On native platforms OneJS intentionally does not define window or document. Many npm libraries check those globals to detect a browser, and silently switch to a non-browser fallback when they are missing. The library still runs, but a feature you expected may be quietly disabled.
WebGL is the exception: there the bootstrap runs in the embedding page's global scope, so window and document are the browser's own. The same bundle therefore takes the browser branch in a WebGL build and the fallback branch on desktop and mobile. If a library behaves correctly in a WebGL test build but misbehaves on device, its browser detection is the first thing to check.
A concrete example: with supabase-js, sign-in succeeds but the session never survives an app restart. Its auth module only persists sessions to localStorage when both window and document exist, so under OneJS it silently falls back to in-memory storage. The fix is to pass an explicit storage adapter, which its code path uses unconditionally:
import { createClient } from "@supabase/supabase-js"
const supabase = createClient(url, anonKey, {
auth: {
storage: {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
},
},
})When a library "works but forgets" or skips a documented feature under OneJS, check its browser detection paths first, then look for an option to inject the dependency explicitly (storage, fetch, WebSocket, and so on). Do not define fake window or document globals to satisfy the detection: other libraries will then assume a full browser and fail in worse ways.
Platform Differences#
| API | Native (QuickJS) | WebGL (Browser) |
|---|---|---|
localStorage | PlayerPrefs | Native localStorage |
fetch | UnityWebRequest | Native fetch |
WebSocket | C# ClientWebSocket | Native WebSocket |
URL | Polyfill | Native URL |
performance.now() | Frame timestamp | Native high-res time |
setTimeout | Unity Update loop | OneJS scheduler, ticked by the browser's RAF |
requestAnimationFrame | Unity Update loop | OneJS scheduler, ticked by the browser's RAF |
The same code works on both platforms. OneJS uses the browser's own APIs in WebGL where it can.