Made to connect

Bring your game.

Your renderer. Your game logic. Touch or external controls.

iOS & AndroidHTML · Three.js · Canvas

The connection

Your game runs inside Charlie. The player imports HTML, a ZIP or an HTTPS URL into the native app, then opens the saved game from their library. Charlie injects window.charlie before your scripts run.

  1. 01Touch or gamepad
  2. 02window.charlie
  3. 03Your game logic

You map the sticks and buttons to your actions. There’s no app-to-game pairing, account or SDK to install. Bluetooth controllers pair through the phone’s settings. This API doesn’t control a separately installed native app or a game running in another browser.

Current compatibility

HTML, ZIP and HTTPS downloads. Up to 20 MB per file and 64 MB per game. Assets and saves stay local. Your game must read the Charlie API; existing keyboard or gamepad support alone won’t connect it.

01 / SETUP

Get connected

Just trying Charlie? The example games are optional demos for players and testers, with browser play and direct import links.

Start with the minimal HTML example or the complete Three.js example. Both are ready to import. The ZIP example also tests separate JavaScript, CSS, a texture and JSON data.

For your own game, wait for ready, then read input during your existing game loop. This snippet assumes your game already defines player, speed, renderer, scene and camera.

Connect your game loop
const controller = window.charlie;
if (!controller) throw new Error('Open this game in Charlie.');
await controller.ready;

let previousTime = performance.now();
function frame(now) {
  requestAnimationFrame(frame);
  const dt = Math.min((now - previousTime) / 1000, 0.05);
  previousTime = now;

  if (!controller.paused) {
    // Use your existing player, speed, scene and renderer.
    player.position.x += controller.input.axes[0] * speed * dt;
    player.position.z += controller.input.axes[1] * speed * dt;
  }
  renderer.render(scene, camera);
}
requestAnimationFrame(frame);

Use a module script or an async function for top-level await. In an ordinary browser, window.charlie is absent; keep your existing browser controls as a fallback if you need them.

02 / INPUT

Sticks & buttons

Read controller.input each frame. It’s replaced as input changes, so don’t hold onto an old snapshot.

Axis mapping
InputFieldDirection
Left stickaxes[0], axes[1]Horizontal, vertical
Right stickaxes[2], axes[3]Horizontal, vertical
D-padup, down, left, rightAlso drives the left axes

Axes range from −1 to 1. Positive X is right; positive Y is down. Analog sticks apply the player’s dead zone and clamp diagonal magnitude.

abxylrstartselectupdownleftright

Button values are truthy while pressed. For one action per press, detect the transition from released to pressed:

One jump per press
let wasPressed = false;
const unsubscribe = controller.onInput(({ buttons }) => {
  if (buttons.a && !wasPressed && !controller.paused) jump();
  wasPressed = !!buttons.a;
});

// When this listener is no longer needed:
// unsubscribe();

onInput calls you immediately with the current state, then whenever input changes. It returns an unsubscribe function. Touch cancellation, controller disconnection, pausing and leaving gameplay clear the controls.

Charlie doesn’t inject keyboard events or a virtual device into navigator.getGamepads(). Connect your actions directly to this API.

02 / HARDWARE

External controllers

Touch and hardware input use the same window.charlie API. Pair a compatible Bluetooth controller in device settings, or connect a compatible USB controller. Open a game or Try the controller to check it.

Charlie uses one controller at a time. Its name appears above the game, and touch controls hide in both orientations. Disconnecting pauses the game and restores touch controls when no compatible pad remains. Release held buttons and center the sticks before resuming.

Hardware button mapping
Controller inputCharlie input
Two sticks and D-padThe same axes and direction buttons as touch
OS-mapped A / B / X / Ya, b, x, y; printed symbols may differ
Left bumper or triggerl
Right bumper or triggerr
Start / Menustart, passed to your game
Select / View / Optionsselect, when exposed by the OS

Triggers activate above 50%. API v1 shares each bumper and trigger action; it has no separate analog trigger values, stick clicks, rumble or multiplayer slots. Use Charlie’s top pause and back controls by touch.

Hardware compatibility is still being tested.

iOS requires an extended gamepad profile; Android requires gamepad or joystick input. Redmagic compatibility depends on the exact model, mode and phone. Keyboard/mouse mapping modes are not supported. Physical Bluetooth, USB and Redmagic testing is still pending.

03 / INTERRUPTIONS

Pause & audio

When the player pauses, the controller disconnects or the app is interrupted, Charlie clears input and sets controller.paused. Skip simulation while it’s true, and clamp elapsed time on return. Your JavaScript isn’t forcibly suspended.

Pause your audio
const unsubscribePause = controller.onPause(paused => {
  if (paused) audioContext.suspend();
});

// Handle resume through a direct tap inside your HTML game.
soundButton.addEventListener('click', async () => {
  if (!controller.paused) await audioContext.resume();
});

The variables above refer to your own audio context and in-game button. Native touch and hardware controller input may not count as web-page activation. Include a direct tap-to-enable-sound control in the game when needed.

onPause listens for changes; read controller.paused for the initial value.

04 / PROGRESS

Save progress

Use the native save slot instead of cookies or browser storage. After ready, load() returns the saved value, or null before the first save.

Load and save
const progress = await controller.load();
score = progress?.score ?? 0;

try {
  await controller.save({ score, level: 2 });
} catch (error) {
  showSaveError(error.message);
}

Save JSON-serializable data up to 256 KiB, measured in UTF-8. The promise resolves when native storage acknowledges the write, and rejects on validation, write failure or timeout. Handle that error in your UI.

One imported file, one save slot.

Each file is identified by its content hash. An updated export becomes a separate library entry with its own save. Removing a game removes its save too. There’s no cloud sync.

05 / DELIVERY

Package & import

Choose + → Files for HTML or ZIP, or + → URL for an HTTPS game page or ZIP download. ZIPs should contain index.html and the built JavaScript, CSS and assets. An enclosing export folder is fine.

  • For ZIPs, include all dependencies and keep relative paths intact. Source TypeScript or JSX must be built first.
  • For one HTML file, inline scripts/styles and embed assets as data URLs.
  • For URL imports, Charlie downloads referenced scripts, styles, images, models and audio for local playback.
  • Don’t rely on cookies, localStorage or IndexedDB. Use load() and save().

Limits: 20 MiB per file, 64 MiB total content and 256 files. ZIP downloads are capped at 64 MiB; the prepared offline export is capped at 96 MiB because embedding assets adds overhead.

A typical esbuild command produces the JavaScript bundle. Include the result as game.bundle.js in your ZIP, or inline it into a self-contained HTML file:

Bundle your JavaScript
npx esbuild src/main.ts --bundle --format=iife \
  --target=es2022 --minify --outfile=game.bundle.js

Import the finished ZIP or HTML export. The downloadable examples already contain their JavaScript.

Save every asset

URL import follows module imports, linked resources, literal asset paths, CSS URLs and glTF image/buffer references. It does not execute the page while downloading, so files whose names are constructed at runtime need an explicit list. Put charlie.json beside the entry page, or import the manifest URL directly:

charlie.json
{
  "name": "My game",
  "entry": "index.html",
  "assets": ["assets/level-1.json", "assets/player.glb"]
}

The manifest can also choose a ZIP’s entry point. Include all dynamically selected files in your ZIP. A URL import looks for the manifest beside the page’s base URL. If guessed asset paths cannot be downloaded, Charlie reports them after saving; check the offline copy and include any required files in the manifest.

A saved web export, ready for offline play.

Downloaded assets are embedded locally. Standard fetch, GET XHR, module imports and image/media sources read those copies. Missing files fail locally. Login sessions, live APIs, multiplayer servers, network workers, scoped import maps and website navigation are not supported. Games must still connect their controls to Charlie.

Make room for the controls

Size your renderer to the game view’s innerWidth and innerHeight, and handle resize. Portrait touch controls use a separate deck; landscape overlays them near the bottom. Keep essential UI clear of those areas. Connecting an external controller hides the deck; disconnecting restores it. Handle these viewport changes as well as device rotation.

Resize a Three.js renderer
function resize() {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / Math.max(1, innerHeight);
  camera.updateProjectionMatrix();
}
addEventListener('resize', resize);
resize();

Try it on a device

  1. Save the HTML file to Files on iOS or Downloads on Android.
  2. In Charlie, tap + → Files and select the HTML or ZIP. For a hosted game, use + → URL.
  3. Open its library card and test sticks and buttons together.
  4. Check rotation, pause/resume, audio, saving and reopening on both platforms. Test hardware connection, disconnection and held-button release on your intended controllers.

Share the completed HTML export with your players. No app-store listing or submission to a Charlie game catalog is part of this integration.

06 / API V1

The whole API

Available as window.charlie. For TypeScript, add the declaration file to your game project.

Charlie API v1 reference
MemberBehavior
versionContract version. Currently 1.
readyPromise resolving after the host supplies initial save data.
inputCurrent four-axis and named-button snapshot.
pausedCurrent pause flag.
onInput(fn)Subscribe to input. Calls immediately; returns unsubscribe.
onPause(fn)Subscribe to pause changes; returns unsubscribe.
load()Promise of a copy of current saved data, initially supplied by native storage.
save(value)Promise resolving after a successful native write.
07 / WHEN SOMETHING’S OFF

Troubleshooting

The buttons don’t do anything.

Wait for charlie.ready, then read the API in your input system. Browser keyboard mappings and Gamepad API support don’t automatically connect. Use Charlie’s controller tester to check the native controls separately.

The game imports, but the screen is blank.

Check for missing assets, live server requests, nested frames and dependencies on browser storage. Include every asset in the ZIP, or list dynamically named URL assets in charlie.json. Offline copies cannot supply a game’s backend. On Android, update Android System WebView if the app requests it.

Audio won’t start.

Include a sound button inside the HTML view and resume the audio context from that direct tap. Touching a native gamepad button may not satisfy the WebView’s activation policy.

My progress disappeared after an update.

A changed export has a new content hash, so it gets a new save slot. Save migration between game versions is not part of API v1.

Does my game have to use Three.js?

No. The API is independent of the renderer. Canvas, Three.js and other browser engines can work if the game meets the packaging requirements and maps Charlie input to its actions.

Start small. Make it yours.

The example cube moves, changes color and saves its position.

Get the Three.js example