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, so localStorage.setItem("volume", "1") writes the PlayerPrefs key onejs:volume. C# code reading a value JavaScript stored adds the prefix. clear(), key() and length cover only these keys, so clear() 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 setItem calls 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)  // Stop

React 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")
MemberWhat 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.persistentDataApplication.persistentDataPath: writable, and kept between sessions
onejs.paths.workingThe app's working directory: ~/ in the Editor, persistentData in a build
onejs.paths.streamingAssetsApplication.streamingAssetsPath
onejs.paths.dataApplication.dataPath
onejs.paths.temporaryCacheApplication.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 memberOlder globalWhere it is covered
onejs.isPlaying__isPlayingLifecycle Hooks
onejs.root__rootThe root element you render() into, as in the Quickstart
onejs.paths, onejs.fs__persistentDataPath, readTextFile and the restFiles above
onejs.stylesloadStyleSheet, compileStyleSheet, removeStyleSheet, clearStyleSheetsStyling: StyleSheet API
onejs.cs.typeOf, onejs.cs.extensions, onejs.cs.release$typeof, useExtensions, releaseObjectC# Interop: Extension Methods
onejs.cs.typeExistsNone: new in 3.9.5C# 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#

APINative (QuickJS)WebGL (Browser)
localStoragePlayerPrefsNative localStorage
fetchUnityWebRequestNative fetch
WebSocketC# ClientWebSocketNative WebSocket
URLPolyfillNative URL
performance.now()Frame timestampNative high-res time
setTimeoutUnity Update loopOneJS scheduler, ticked by the browser's RAF
requestAnimationFrameUnity Update loopOneJS 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.