How CloudMoon maps keyboard and mouse to Android touches

A full trace of CloudMoon's input pipeline: touch ID allocation, pointer lock, virtual joysticks, per-game key maps, and eight config archetypes with worked examples.

Neo-brutalist cover reading CloudMoon Input Pipeline, tagged keyboard to touch, WebRTC and pointer lock.

Overview

CloudMoon is a cloud gaming platform that streams Android games to web browsers via WebRTC. The client is a Vue.js application that translates desktop inputs (keyboard, mouse, scroll wheel) into Android touch events, sent over a WebRTC data channel to a remote Android instance.

On mobile, the client is a thin passthrough — real finger touches are forwarded 1:1 to the Android device, and the game's own on-screen controls are used directly. The entire config/mapping system exists exclusively for desktop users who lack a touchscreen.

This document covers the full architecture: connection flow, input pipeline, control mapping system, game archetypes, mobile vs desktop behavior, monetization, and notable design decisions.


Architecture Summary

Browser (Vue.js)
    │
    ├── Socket.IO ──► Coordinator Server (signaling)
    │
    └── WebRTC PeerConnection
            ├── Video track ──► <video> element (game stream)
            ├── Audio track ──► <audio> element
            └── DataChannel ("config", ordered+reliable)
                    ├── Outbound: touch, keyboard, sensor, resolution
                    └── Inbound: rotation, stats, keyboard show/hide, kickout

Key files:

  • app.js — Vue instance, initialization, event listeners, config loading
  • app-methods.js — All input handling, WebRTC, socket logic
  • app-data.js — Reactive state/defaults
  • gameConfig.js — Bundled per-game control configs (imports all com_*.js)
  • com_*.js — Individual game config overrides (one per Android package name)
  • keyToKeyCodeMap.js — Maps key names to numeric keycodes (including fake codes: lclick: 991, rclick: 992)
  • index.html — Vue template, canvas event binding, parent frame bridge, nenly config globals

Connection Flow

  1. Page load → Vue mounted() fires
  2. Config fetch → GET api.prod.cloudmoonapp.com/web/game_config?pkg={game} — falls back to bundled gameConfig.js if the API fails. This means CloudMoon can update control schemes server-side without redeploying the frontend.
  3. Ad check → POST api.prod.cloudmoonapp.com/web/ad — determines ad display, free time limits, ad blocker status. Skipped entirely on mobile.
  4. Socket.IO connect → Connects to coordinator server via websocket at /client/socket.io
  5. Room join → client_register event with game, userId, instanceId, screen resolution
  6. SDP offer received → Server sends WebRTC offer with video/audio
  7. SDP answer created → Client injects codec preferences (H264/VP8), bitrate caps (x-google-max-bitrate, b=AS:), and RRTR extensions
  8. ICE candidates exchanged → Host candidates are filtered out (only relay/srflx used)
  9. Data channel opens → Client sends res>{width}x{height} to set Android resolution
  10. Sensor sync starts → Fake accelerometer/gyro data sent every 100ms to force screen orientation
  11. Game loop starts → requestAnimationFrame loop for virtual joystick updates
  12. Stats polling → getStats() every 1s for jitter buffer tuning; reportStates() every 3s via data channel; reportCoordinator() every 3s via socket

Data Channel Protocol

All messages are strings sent over a single ordered, reliable WebRTC data channel named "config".

Outbound (Client → Android)

PrefixFormatPurpose
touch>Touch start:{id},{x},{y}Finger down at coordinate
touch>Touch move:{id},{x},{y}Finger drag
touch>Touch end:{id},{x},{y}Finger up
touch>Touch endcancel:{id},{x},{y}Touch cancelled
touch>Mouse down:{button},{x},{y}Mouse button press (normal mode)
touch>Mouse up:{button},{x},{y}Mouse button release (normal mode)
touch>Mouse move:{button},{x},{y}Mouse movement (normal mode)
touch>Text:{char}Single character input
touch>TextCmd:{key}Special key (Enter, Backspace, etc.)
touch>KeyUp:{key}Single-char key release (on window blur)
touch>KeyUpCmd:{key}Modifier/special key release (Alt, Ctrl, Shift, Meta)
softinput>switch:remote / switch:localToggle between remote/local IME
softinput>text_change:{cnt}:0,99999,{text}Text field content update
softinput>action:{type}Submit/enter action (type 4 = done)
softinput>hideDismiss soft keyboard
softinput>height:1Report keyboard height
sensor>accelerate:{x}_{y}_{z}Fake accelerometer data
sensor>rotate:{a}_{b}_{c}_{d}_{e}Fake rotation sensor data
res>{width}x{height}Set streaming resolution
stats>(empty)Request stats from server
toggleVideo>on / offMute/unmute video stream
adBlock>true / falseReport ad blocker status
keycode>{code}Android keycode (e.g. 4 = BACK)
game>restartForce-restart the game app

Multi-touch format

Multiple simultaneous touches are colon-separated:

touch>Touch start:{id1},{x1},{y1}:{id2},{x2},{y2}

Inbound (Android → Client)

PrefixPurpose
rotation>Screen orientation changed (0=portrait, 1=landscape)
hide_cursor>Show/hide cursor (1 = hide)
touchState>Enable/disable touch input
keyboard>show opens soft input; hide closes it; verify for verification prompts
stats>Server-side streaming metrics (BWE, bitrate, FPS, RTT, codec, resolution)
cloudData>Arbitrary data from game — parsed with eval()
pod_name>Identifier of the Android pod
kickout>Force disconnect (session ended)
locale>Pod locale information
remotePay>Triggers native Android payment bridge
inputDelay>Input latency measurement

The Core Input Problem

Android games only understand touch events — finger down, finger move, finger up at specific pixel coordinates. Desktop browsers produce keyboard and mouse events. CloudMoon's entire input system is a translation layer that converts one to the other.

The fundamental insight: every button, joystick, and control in an Android game is just a region of pixels on the screen. If you know where the "jump" button is rendered (say, at coordinate 1180,470), you can simulate tapping it by sending a touch event at that exact position.

All coordinates exist in a 1280x720 virtual Android screen space. This is the universal coordinate system that every config, every translation function, and every touch event operates within.


Mobile vs Desktop: Two Completely Different Paths

Mobile: Direct Touch Passthrough

On mobile devices (detected by isMobile() via user agent), CloudMoon is essentially a thin WebRTC video player with touch forwarding. The game's own on-screen controls (its native joystick, its native buttons) are visible in the video stream, and the user taps them directly.

The canvas has both mouse and touch event handlers:

<canvas id="canvasCoc"
  @mousemove.prevent="mousemove"    <!-- desktop -->
  @mousedown.prevent="mousedown"    <!-- desktop -->
  @mousewheel.prevent="mousewheel"  <!-- desktop -->
  @mouseup.prevent="mouseup"        <!-- desktop -->
  @touchstart.prevent="touchstart"  <!-- mobile -->
  @touchmove.prevent="touchmove"    <!-- mobile -->
  @touchend.prevent="touchend"      <!-- mobile -->
  @touchcancel.prevent="touchcancel" <!-- mobile -->

The mobile touch handlers are simple passthroughs:

touchstart(e) {
  let t = "touch>Touch start" + this.getChangedTouchesString(e);
  this.sendDataChannelMessage(t);
}

The only processing is:

  • Coordinate scaling — browser pixels are scaled to Android resolution via resizeHandler()
  • Rotation mapping — if the Android is landscape but the browser is portrait (or vice versa), getMousePos() swaps X/Y axes
  • Multi-touch — multiple simultaneous fingers are colon-separated in a single message
  • Touch ID assignment — browser touch identifiers are mapped to stable IDs via getTouchCounter()

Desktop: Full Virtual Controller

On desktop, the entire config system activates. Every feature below exists exclusively for desktop users:

FeatureHow it's disabled on mobile
Keyboard overlayv-show="... && !isMobile()" in HTML
Key bindingslistenKeyDown/listenKeyUp only fire from physical keyboards
Virtual joysticks (WASD/ZXCV)Only driven by keyboard key presses
Right-click cameraNo right-click on touchscreens
Pointer lock (Ctrl+M)No mouse to lock
Scroll → pinch zoomNo scroll wheel
Edit hotkeys menuHidden behind !isMobile() check
Mouse sensitivity sliderOnly shown when supportPointerLock is true
AdscheckAD() immediately returns on mobile

Touch ID Allocation

Each simultaneous "finger" on the Android screen needs a unique ID. CloudMoon uses a fixed allocation scheme:

IDPurposeLifetime
0-4Reserved (real touch events from mobile passthrough)Dynamic
5Left-click tap in pointer lock modePress & release
6Pinch-to-zoom finger 1Scroll wheel gesture
7Pinch-to-zoom finger 2Scroll wheel gesture
8Camera drag (right-click or pointer lock movement)Drag duration
9Right-click tap in pointer lock modePress & release
10Left virtual joystick (WASD)While keys held
11Right virtual joystick (ZXCV)While keys held
12+Keyboard key bindingsAssigned incrementally via getTouchCounter()

The touchCounter starts at 11 in app-data.js. IDs are assigned once per key and cached in a Map, so a given key always uses the same touch ID for the entire session.


Desktop Input Modes

Mouse Protocol: Mouse vs Touch Messages

A critical distinction: CloudMoon sends two different types of messages depending on context:

  • Mouse down/up/move — These tell Android "a mouse did this." Android interprets them as mouse-source events, which games may handle differently from touch.
  • Touch start/move/end — These tell Android "a finger did this." Android interprets them as real touch events.

Normal mode left-click uses Mouse messages. Everything else (right-click drag, pointer lock, joysticks, keyboard taps) uses Touch messages. This matters because some Android games distinguish between mouse and touch input.

Normal Mode (default)

Left-Click: Direct Mouse Passthrough

Left-click in normal mode sends Mouse protocol messages with the cursor's exact position:

  • Mousedown → Mouse down:0,{x},{y} — the 0 is the mouse button index (left button)
  • Mousemove while held → Mouse move:0,{x},{y} — but only if sendMove is true (see below)
  • Mouseup → Mouse up:0,{x},{y}

Coordinates are scaled from browser pixels to Android resolution via resizeHandler(), with rotation-aware axis swapping in getMousePos().

The sendMove Gate

sendMove is a boolean flag that starts false and becomes true only when a mousedown happens. It goes back to false on mouseup. This prevents stray mousemove events — without this gate, every mouse movement over the canvas would send Mouse move messages to Android, even when no button is pressed.

There is one exception: the URL parameter always_send_move=true bypasses this gate entirely, sending every mousemove regardless of button state. This is useful for games that need hover detection.

Right-Click Camera Drag (supportRightClick: true)

When not in pointer lock mode, holding right-click initiates a camera drag using Touch messages (not Mouse messages):

  1. Mousedown (right button) → Touch start:8,{centerX},{centerY} — the finger "presses" at the configured rightClickCenter (default 640,360)
  2. Mousemove while held → Touch move:8,{centerX + deltaX},{centerY + deltaY} — the finger drags away from center, clamped to rightClickRadius (default 350 pixels)
  3. Mouseup → Touch end:8,{finalX},{finalY} — the finger lifts

The rightClickRadius clamp works as a circular boundary: the distance from center to the current position is limited to rightClickRadius pixels. If the mouse moves further, the touch position stops at the circle's edge.

rightClickRadius is a game-type indicator:

  • 350 (default) → Full camera drag arc (RPGs, shooters)
  • 150 → Attack joystick (Brawl Stars — dragging within a smaller radius aims an attack)
  • 1 → Fixed tap (MOBAs like Mobile Legends — radius of 1 pixel means there's no drag at all, just a tap at rightClickCenter)

Left-Click When supportRightClick is OFF

If the game config doesn't support right-click, the left-click path changes: instead of Mouse messages, it sends Touch messages. This is because games that don't use right-click typically need pure touch input:

  • Mousedown → Touch start:{id},{x},{y}
  • Mousemove → Touch move:{id},{x},{y}
  • Mouseup → Touch end:{id},{x},{y}

mouseleave Handler

When the cursor leaves the canvas area (alt-tabbing, moving to browser chrome, etc.), the system sends a synthetic mouseup. This prevents "stuck" mouse buttons — without it, the Android side would think the user is still holding the button forever.

Pointer Lock Mode (pointerLock: true) — Deep Dive

Activated by pressing Ctrl+M. The cursor is captured and hidden (FPS-style). This mode completely changes how mouse events are interpreted.

The Core Problem: Deltas vs Positions

In pointer lock mode, the browser gives you relative deltas — "the mouse moved 5 pixels right, 3 pixels down." But Android needs absolute positions — "the finger is at coordinate 645, 363." CloudMoon must convert one into the other.

Accumulation: Building Position from Deltas

CloudMoon maintains two running totals: moveX and moveY, both starting at 0. Each mousemove event adds its delta to these totals:

Frame 1: mouse moves +5 right    → moveX = 0 + 5 = 5
Frame 2: mouse moves +3 right    → moveX = 5 + 3 = 8
Frame 3: mouse moves -2 left     → moveX = 8 - 2 = 6
Frame 4: mouse moves +10 right   → moveX = 6 + 10 = 16

The accumulated value is then added to the center point to get the absolute finger position:

fingerX = pointerLockCenter.x + moveX
fingerY = pointerLockCenter.y + moveY

Each delta is multiplied by mouseSensitivity (user-adjustable 0.5–2.0, persisted per game in localStorage) before accumulation. Touch events are sent immediately on every mousemove — there is no buffering.

Clamp: Preventing Off-Screen Positions

Without limits, moveX would grow forever as the user keeps moving the mouse right. The accumulated position could go far off-screen, producing invalid coordinates. CloudMoon clamps the accumulated value:

moveX is clamped to: -(videoWidth/2)  to  +(videoWidth/2)
moveY is clamped to: -(videoHeight/2) to  +(videoHeight/2)

For a standard 1280x720 stream, that's ±640 horizontally and ±360 vertically.

What happens when clamped: The finger position gets stuck at the screen edge. If the user keeps moving the mouse right after hitting the right clamp, moveX stays at +640 — the finger doesn't move, the camera stops rotating. The mouse movement is effectively "wasted" until the user moves the mouse back in the opposite direction (which reduces moveX below the clamp) or stops moving for 200ms (which resets everything).

Clamp asymmetry with off-center configs: The clamp range is always ±(half video dimensions), regardless of where pointerLockCenter is. If the center is at {700, 360} (like Fortnite), the finger can reach 700 + 640 = 1340 on the right (off-screen) but only 700 - 640 = 60 on the left. This creates a slight asymmetry where you can "waste" more mouse movement in one direction than the other.

The 200ms Debounce Timer: Detecting "Mouse Stopped"

Browsers have no mousestop event. There's mousemove, but nothing that fires when the mouse stops moving. CloudMoon needs to know when the user stops because:

  1. The Touch on Android needs to lift (Touch end) — otherwise the finger stays pressed on the screen forever
  2. The accumulated position needs to reset to zero — otherwise the next mouse gesture starts from wherever the last one ended, making rotation lopsided

The solution: a 200ms debounce timer.

Every mousemove:
  1. Cancel existing 200ms timer (if any)
  2. Start a new 200ms timer
  3. Process the movement normally (accumulate, clamp, send Touch move)

If 200ms passes with no mousemove:
  1. Timer fires
  2. Send Touch end:8 (finger lifts)
  3. Reset moveX = 0, moveY = 0
  4. Reset pointerLockStart = false
  5. Next mousemove will begin a fresh Touch start:8 from center

This means: every time the user pauses mouse movement for 200ms, the system "lifts the finger" and resets. The next mouse movement starts a brand new gesture from the center point.

Without the 200ms reset, the accumulated moveX would drift toward the clamp edge over time. Eventually the finger would be stuck at the screen edge, and the camera could only rotate in one direction. The reset is what keeps the system usable across multiple gestures.

Pointer Lock Clicks

Clicks in pointer lock mode are simple fixed-position taps — they don't use the cursor position at all:

  • Left click → Touch ID 5 tap at a fixed coordinate. Priority order: keyBoard.lClick.coordinate (if not "0,0") → pointerLockCoordinate.lClick → default 1060,550
  • Right click → Touch ID 9 tap at a fixed coordinate. Priority order: keyBoard.rClick.coordinate (if not "0,0") → pointerLockCoordinate.rClick → default 1180,620

The priority system means users can reposition where their mouse clicks land by dragging the LMB/RMB overlay icons in the key editor.

Complete Mouse Path Decision Tree

mousedown event
  ├── Pointer lock active?
  │     ├── Left button (button 0)
  │     │     └── Touch start:5 at lClick coordinate
  │     │         (priority: keyBoard.lClick → pointerLockCoordinate.lClick → default)
  │     └── Right button (button 2)
  │           └── Touch start:9 at rClick coordinate
  │               (priority: keyBoard.rClick → pointerLockCoordinate.rClick → default)
  │
  └── Normal mode
        ├── Right button + supportRightClick?
        │     └── Touch start:8 at rightClickCenter
        ├── Left button + supportRightClick?
        │     └── Mouse down:0,{x},{y}  (Mouse protocol, not Touch)
        └── Left button + NO supportRightClick?
              └── Touch start:{id},{x},{y}  (Touch protocol)

mousemove event
  ├── Pointer lock active?
  │     ├── First move? → Touch start:8 at center (pointerLockStart = true)
  │     ├── Accumulate: moveX += delta.x * sensitivity
  │     ├── Clamp: moveX to ±(videoWidth/2)
  │     ├── Send Touch move:8,{center.x + moveX},{center.y + moveY}
  │     └── Reset 200ms debounce timer
  │
  └── Normal mode
        ├── sendMove is false? → SKIP (no button held)
        ├── Right-click drag active?
        │     └── Touch move:8,{center + delta, clamped to rightClickRadius}
        └── Left-click active?
              ├── supportRightClick? → Mouse move:0,{x},{y}
              └── No supportRightClick? → Touch move:{id},{x},{y}

mouseup event
  ├── Pointer lock active?
  │     ├── Left button → Touch end:5 at lClick coordinate
  │     └── Right button → Touch end:9 at rClick coordinate
  │
  └── Normal mode
        ├── Right-click drag active? → Touch end:8
        ├── supportRightClick? → Mouse up:0,{x},{y}
        └── No supportRightClick? → Touch end:{id},{x},{y}

mouseleave event → Synthetic mouseup (prevents stuck buttons)

200ms timer fires (pointer lock only) → Touch end:8 + reset moveX/moveY to 0

Right-Click Drag vs Pointer Lock: Same Concept, Different Clamp Shapes

Both systems do the same fundamental thing: translate mouse deltas into a finger drag from a fixed center point using Touch ID 8. The difference is how they limit the drag range:

Right-click drag → Circular clamp — calculates the distance from center to current position, and if it exceeds rightClickRadius, caps it on the circle edge. Diagonal movement gets the same max range as cardinal movement.

Pointer lock → Rectangular clamp — clamps X and Y independently (±videoWidth/2 and ±videoHeight/2). Moving diagonally, the finger can actually reach further from center than moving cardinally (the corner of the rectangle is ~735 units from center for a 1280x720 stream, while the horizontal edge is only 640).

Right-click drag:          Pointer lock:
     ___                   ┌─────────┐
   /     \                 │         │
  |   ·   |  radius        │    ·    │  ±640 x ±360
   \_____/                 │         │
                           └─────────┘
  circle                   rectangle

The right-click version uses a circle because game joysticks and camera drag zones are circular. The pointer lock version uses a rectangle because the screen is rectangular — clamping to the screen boundary makes more sense than clamping to an arbitrary circle when the goal is "finger can reach anywhere on screen."

Other differences:

Right-Click DragPointer Lock
Clamp shapeCircle (radius)Rectangle (screen halves)
Configurable boundaryYes (rightClickRadius per game)No (always half video dimensions)
Reset mechanismMouseup (user releases button)200ms debounce timer (auto-detects stop)
Stays active untilUser releases right-clickUser stops moving for 200ms
Center point configrightClickCenterpointerLockCenter
Used forCamera drag, attack aim, fixed tapFPS-style free look

Why pointer lock exists for games that already have right-click drag: Take Genshin Impact — it supports both. In normal mode, right-click drag gives a max range of 350 pixels (the default rightClickRadius) from center. In pointer lock, the range is 640 pixels horizontally, 360 vertically — roughly 2x the camera range per gesture. During combat, you need fast wide camera swings. A 350px circle isn't enough for a full camera spin, so you'd have to release and re-drag multiple times. Pointer lock's larger rectangle (plus the 200ms auto-reset that starts each gesture fresh from center) lets you do bigger sweeps before hitting the clamp. That's the practical reason games like Genshin offer both modes.


Virtual Joysticks (Rockers)

Left Rocker (supportLeftRocker: true, Touch ID 10)

Driven by WASD keys. Runs on a requestAnimationFrame game loop (gameLoop() → updateLeftPosition()), not on key events directly:

Key press → sets flag in leftRockerPressedKeys
                    ↓
gameLoop() runs at ~60fps via requestAnimationFrame
                    ↓
updateLeftPosition() checks which keys are held
                    ↓
Moves joystick position by dStep (4 units) per frame
                    ↓
Clamps to circle of dRadius (120 units) from center
                    ↓
Adds anti-detection jitter (+/-1 on secondary axis)
                    ↓
Sends Touch move:10,{x},{y} each frame the position changes

This produces smooth, continuous movement rather than discrete taps. The joystick position gradually accelerates to the edge of the radius (120/4 = 30 frames = ~0.5 seconds from standstill to full tilt).

Anti-detection jitter: When the joystick is moving in a cardinal direction (pure W/A/S/D), the secondary axis alternates +1/-1 each frame. This prevents perfectly straight touch paths that games could detect as bot input. The jitter is deterministic (alternating state), not random.

Lifecycle:

  1. First key press → Touch start:10,{cx+dx},{cy+dy} + leftRockerStart = true
  2. Each frame while keys held → Touch move:10,{cx+dx},{cy+dy}
  3. All keys released → Touch end:10,{cx+dx},{cy+dy}, position resets to {0,0}, leftRockerEnd = true

Right Rocker (supportRightRocker: true, Touch ID 11)

Driven by ZXCV keys. Same mechanics as left rocker but:

  • Uses rightCenter from config instead of center
  • Jitter uses Math.random() < 0.5 ? -1 : 1 instead of alternating state (random, not deterministic)
  • Currently only used by one game (YZGAME — a fighting game needing dual joysticks)

Window Blur Cleanup

When the browser window loses focus (window.onblur), the system performs a comprehensive cleanup:

  1. Every currently pressed key sends its KeyUp/KeyUpCmd release event
  2. Left rocker pressed keys are cleared, joystick sends Touch end:10 and resets
  3. Right rocker pressed keys are cleared, joystick sends Touch end:11 and resets
  4. All visual pressed-key CSS classes are removed

This prevents "stuck inputs" when alt-tabbing, switching windows, or clicking outside the game.

Joystick Design Tradeoff: Gradual Ramp vs Instant Snap

The current joystick moves at 4 units per frame toward the target direction, reaching the 120-unit radius edge in ~30 frames (~0.5 seconds). An alternative design would be to instantly snap the finger to the edge position when a key is pressed (e.g., pressing W immediately places the finger at the top of the joystick circle).

Why CloudMoon chose gradual ramping:

  • Anti-cheat safety — A real human finger takes time to drag from the center to the edge of a joystick. An instant teleport from center to max radius looks like bot input. Anti-cheat systems in games like Fortnite and PUBG specifically look for suspiciously fast touch movements.
  • Smooth acceleration — Many games have acceleration-sensitive joysticks where the distance from center affects movement speed. Gradual ramp provides a natural acceleration curve.

What they sacrifice:

  • ~0.5 second response delay — From key press to full-speed movement, there's a noticeable lag. In competitive games, this puts cloud users at a disadvantage vs mobile users who can instantly swipe to the edge.
  • Complexity — The requestAnimationFrame game loop, frame-by-frame stepping, jitter injection, and start/end lifecycle management are all necessary only because of gradual movement. Instant snap would be a few lines of code.

The tradeoff is anti-cheat detection vs responsiveness. CloudMoon chose to look more human at the cost of being slower.


Traced Examples: Config Through Code

To illustrate how the config flags change behavior, here are three games traced through the actual code paths.

Example 1: Brawl Stars — Right-Click as Attack Joystick

Config:

supportLeftRocker: true
supportRightClick: true
rightClickCenter: {1085, 435}
rightClickRadius: 150
pointerLock: false

User presses W:

  1. listenKeyDown → W key detected → supportLeftRocker is true → routes to leftRockerPressedKeys
  2. gameLoop() → updateLeftPosition() → moves joystick 4 units toward top of circle → Touch move:10,{153},{565 - 4}
  3. Each frame: moves 4 more units → Touch move:10,{153},{561}, {153},{557}, etc.
  4. After ~30 frames: reaches radius edge (120 units from center) → position clamps → continues sending same position with jitter

User presses Space:

  1. listenKeyDown → Space → looks up keyBoard[" "].coordinate = "1075,470" → not "0,0" → sends Touch start:{id},1075,470
  2. On keyup → Touch end:{id},1075,470

User right-clicks and drags right:

  1. mousedown (button 2) → supportRightClick is true, not pointer lock → Touch start:8,1085,435 (at rightClickCenter)
  2. mousemove (delta +80, 0) → new position = {1085+80, 435} = {1165, 435} → distance from center = 80 < 150 (radius) → not clamped → Touch move:8,1165,435
  3. mousemove (delta +200, 0) → raw position = {1165+200, 435} = {1365, 435} → distance from center = 280 > 150 → clamped to 150 on the circle edge → sends Touch move:8,1235,435
  4. mouseup → Touch end:8,1235,435

In Brawl Stars, this right-click drag is aiming the attack — dragging within the 150px circle sets the attack direction, and releasing fires. The radius of 150 matches the game's on-screen attack joystick size.

Example 2: Genshin Impact — Pointer Lock with Click Overrides

Config:

supportLeftRocker: true
supportRightClick: true
pointerLock: true
pointerLockCoordinate: {lClick: "1060,550", rClick: "1180,620"}
keyBoard.lClick.coordinate: "0,0"   (default, visual only)
keyBoard.rClick.coordinate: "0,0"   (default, visual only)
center: {200, 550}

Normal mode — user right-clicks and drags: Same as any right-click game: Touch start:8 at {640,360} (default rightClickCenter), drag with Touch ID 8, radius clamped to 350 (default). This rotates the camera in exploration mode.

User presses Ctrl+M → enters pointer lock: Cursor disappears. All mouse behavior changes.

User moves mouse right (+50 delta):

  1. pointerLockStart is false → Touch start:8,640,360 (at pointerLockCenter default)
  2. pointerLockStart = true
  3. moveX += 50 * sensitivity (say sensitivity = 1.0) → moveX = 50
  4. Clamp check: 50 < 640 (half of 1280) → no clamp
  5. Touch move:8,{640+50},{360+0} = Touch move:8,690,360
  6. Start 200ms timer

User keeps moving right (+400 more):

  1. Cancel old timer, start new timer
  2. moveX += 400 → moveX = 450
  3. Still under 640 clamp → Touch move:8,1090,360

User stops moving for 200ms:

  1. Timer fires → Touch end:8,1090,360
  2. moveX = 0, moveY = 0, pointerLockStart = false
  3. Next mouse movement starts fresh from center

User left-clicks (in pointer lock):

  1. Check keyBoard.lClick.coordinate = "0,0" → skip
  2. Check pointerLockCoordinate.lClick = "1060,550" → use this
  3. Touch start:5,1060,550 → Touch end:5,1060,550
  4. This taps the attack button in Genshin's UI

User drags the LMB overlay icon in the editor to position 800,400:

  1. Now keyBoard.lClick.coordinate = "800,400" (non-zero)
  2. Next left-click: check keyBoard.lClick.coordinate = "800,400" → not "0,0" → uses this instead
  3. Touch start:5,800,400 → the click now lands at the user's custom position

Example 3: YZGAME — Dual Joystick with "0,0" Keys

Config:

supportLeftRocker: true
supportRightRocker: true
supportRightClick: false
center: {120, 480}
rightCenter: {1160, 480}
keyBoard.w.coordinate: "0,0"
keyBoard.z.coordinate: "0,0"
keyBoard.arrowup.coordinate: "333,515"

User holds W and Z simultaneously:

  1. listenKeyDown(W) → supportLeftRocker true → routes to leftRockerPressedKeys.w = true
  2. listenKeyDown(Z) → supportRightRocker true → routes to rightRockerPressedKeys.z = true
  3. gameLoop() each frame:
    • updateLeftPosition(): W held → move up 4 units → Touch move:10,{120},{480 - step}
    • updateRightPosition(): Z held → move up 4 units → Touch move:11,{1160},{480 - step}
  4. Both joysticks run simultaneously — character moves up while attacking upward

Why W has coordinate: "0,0": Without the "0,0" convention, pressing W would trigger both the rocker routing (via supportLeftRocker check) and the normal key-to-touch mapping (via keyBoard.w.coordinate). The "0,0" check ensures the key-to-touch path does nothing:

if (o && "0,0" !== o) { // o = "0,0" for W → condition fails → no touch event
  this.sendDataChannelMessage(`touch>Touch start:${e},${o}`);
}

The W key still appears in the visual overlay (showing users that WASD controls the joystick), but only the rocker system generates touch events.

User presses Arrow Up:

  1. listenKeyDown(ArrowUp) → not W/A/S/D → not Z/X/C/V → falls through to normal key handling
  2. Looks up keyBoard.arrowup.coordinate = "333,515" → not "0,0" → sends Touch start:{id},333,515
  3. This is a separate button in the game — arrow keys are real coordinate taps, not joystick directions

Summary: How Config Flags Change Behavior

ScenarioConfigMouse Left-ClickMouse Right-ClickWASD KeysPointer Lock
RPG (Genshin)rocker + rClick + pLockMouse down passthroughTouch ID 8 camera drag (radius 350)Left joystick (Touch ID 10)Ctrl+M → Touch ID 8 accumulate, clicks at fixed coords
Shooter (Brawl Stars)rocker + rClick(150)Mouse down passthroughTouch ID 8 attack aim (radius 150)Left joystick (Touch ID 10)N/A (pointerLock: false)
MOBA (Mobile Legends)rocker + rClick(1)Mouse down passthroughTouch ID 8 fixed tap (radius 1 = no drag)Left joystick (Touch ID 10)N/A
Fighting (YZGAME)both rockers, no rClickTouch start (no right-click → touch protocol)Nothing (disabled)Left joystickN/A
Casual (Among Us)rocker onlyTouch start (no right-click → touch protocol)Nothing (disabled)Left joystickN/A

Keyboard → Touch Mapping System

The Config Format

Each entry in the game config's keyBoard object maps a keyboard key to an Android screen coordinate:

{
  " ": {
    str: "Space",           // Display label on overlay
    key: "space",           // Event key name for matching
    location: "",           // CSS position override (legacy)
    coordinate: "1142,575", // Android screen x,y (the actual touch target)
    size: "small"           // Overlay button size ("small" or "large")
  }
}

The Two Coordinate Systems

Every key binding has two positioning properties that serve different layers:

coordinate — The Android screen position (in 1280x720 space) where the touch event actually lands on the remote device. This is what the game receives.

location — CSS positioning string for the visual overlay button in the browser (e.g. "left: 92%; bottom: 35%;").

When location is empty "", the overlay position is automatically computed from coordinate:

left: (x / 1280) * 100 + "%"
bottom: ((720 - y) / 720) * 100 + "%"

When location has a value, it takes priority for visual positioning. This allows the overlay button to be placed differently from where the touch lands — useful when placing the visual indicator at the exact Android coordinate would obscure gameplay.

Older configs tend to have location filled in; newer ones leave it empty and let coordinate drive both positioning.

The coordinate: "0,0" Convention

"0,0" is used as a "disabled" / "visual-only" marker. The code explicitly checks for it:

if (o && "0,0" !== o) {
  this.sendDataChannelMessage(`touch>Touch start:${e},${o}`);
}

Keys with "0,0" appear in the overlay but generate no touch events. Three use cases:

  1. WASD/ZXCV visual indicators — When rockers are enabled, the rocker system handles the actual continuous touch drag. The WASD keys exist in keyBoard with "0,0" coordinate purely so the overlay shows labeled buttons where the joystick is.
  2. LMB/RMB indicators — Shown in the overlay with mouse SVG icons to tell users "your mouse buttons do something." The mouse handling code handles the actual events separately via mousedown/mouseup.
  3. Disabled pointer lock clicks — lClick/rClick default to "0,0", meaning pointer lock mode uses pointerLockCoordinate values from the top-level config. But if a user drags the LMB/RMB overlay to a new position in the editor, the coordinate becomes non-zero and overrides the config-level default.

LMB and RMB as "Virtual Keys"

lClick and rClick are entries in the keyBoard config like any other key, with fake keycodes in keyToKeyCodeMap (lclick: 991, rclick: 992). They:

  • Render in the overlay as mouse button SVG icons (not text labels)
  • Are draggable in the key editor like any other key
  • When coordinate is "0,0" → purely visual, mouse clicks use pointerLockCoordinate
  • When coordinate is non-zero → the mousedown/mouseup handler checks keyBoard.lClick.coordinate / keyBoard.rClick.coordinate first and uses that instead

This means users can reposition where their mouse clicks land in pointer lock mode by simply dragging the LMB/RMB icons in the editor. The visual indicator and the functional touch target move together.

Keyboard Event Flow (Desktop Only)

Here's the exact path a keypress takes:

  1. listenKeyDown(e) fires from the document keydown listener
  2. Guards skip if: input/textarea is focused, edit mode is active, key is F1-F12, or key is already held (prevents browser key repeat)
  3. Ctrl+M intercept — if Ctrl+M and pointerLock is enabled, toggles pointer lock instead of sending a key event
  4. Key repeat prevention — keyDownMap[e.key] = true on first press; subsequent keydown events for the same key are ignored
  5. Rocker routing — if the key is W/A/S/D and supportLeftRocker is on, it goes into leftRockerPressedKeys (handled by the game loop, not here). Same for Z/X/C/V with supportRightRocker.
  6. Visual feedback — CSS class keyboard-key-press is added to the overlay button (found via .keyboard-key-{keycode} selector)
  7. Touch generation — looks up currentGameConfig.keyBoard[key].coordinate, and if not "0,0", sends touch>Touch start:{id},{x},{y}

On keyup (listenKeyUp):

  1. keyDownMap[e.key] = false
  2. Modifier keys (Alt, Ctrl, Shift, Meta) get explicit KeyUpCmd messages if their flag is inconsistent
  3. If it was a rocker key and all rocker keys are released, sends Touch end for the joystick
  4. Removes keyboard-key-press CSS class
  5. Sends touch>Touch end:{id},{x},{y} for the key's coordinate

Keyboard Overlay Visibility

The overlay has multiple visibility conditions:

v-show="currentGameConfig
  && currentGameConfig.keyBoardDirection === rotation  ← must match game orientation
  && showKeyboard                                       ← user can toggle off
  && !isMobile()"                                       ← never shown on mobile

Individual keys within the overlay support context-dependent visibility:

v-show="(!cursorLock && !item.pointerLockShow)
     || (cursorLock && item.pointerLockShow)
     || (!item.pointerLockShow && !item.pointerLockHidden)"

Keys can have:

  • pointerLockShow: true — only visible when pointer lock is active
  • pointerLockHidden: true — hidden when pointer lock is active
  • Neither — always visible

This enables different visual layouts for pointer lock vs normal mode. None of the current bundled configs use these flags, but the server-side API configs may.

The overlay dimensions match the video but swapped: width: videoHeight, height: videoWidth. The overlay is sized to the video element and may be rotated via CSS transforms to match the game orientation. When keyRotate is true, individual key labels get transform: rotate(270deg).

Key Editor

Users can customize key mappings through an in-game editor:

  • Add keys — Opens a SweetAlert modal that captures a keypress, places the new key at a random position near center (640 +/- 50, 360 +/- 50). New keys get a bling: true animation flag.
  • Drag keys — Pointer drag recalculates both the CSS position and the coordinate from the drag position mapped to 1280x720 space
  • Drag the joystick center — Repositions the left rocker's center origin point
  • Remove keys — Deletes from config, with an X button overlay per key
  • Reset — Fetches fresh config from the API (or falls back to bundled default) and clears localStorage

Restrictions:

  • WASD are blocked from binding when supportLeftRocker is enabled (they're reserved for the joystick)
  • F1-F12 keys are always blocked
  • Duplicate keys are rejected
  • Keys with invalid keycodes (not in keyToKeyCodeMap) are rejected

Three layout slots (layout1, layout2, layout3) per game, persisted in localStorage with keys like:

keyboardLayout:{layout}:{game}:newKeyboard  → JSON of keyBoard object
keyboardLayout:{layout}:{game}:center       → JSON of center position

Layout migration: if old-format keys exist (without layout prefix), they're migrated to the current layout slot and the old keys are deleted.


Scroll Wheel → Pinch-to-Zoom

Mouse scroll is translated into a two-finger pinch gesture using touch IDs 6 and 7:

  • Scroll down (negative wheelDelta) → Fingers move apart (zoom in/out): ID 6 moves right+down, ID 7 moves left+up
  • Scroll up (positive wheelDelta) → Fingers move together: reverse direction
  • Animated over 4 steps (0, 16, 32, 48 pixels) with setTimeout delays of 2.5ms * step index
  • Throttled: one zoom event per 300ms (this.zoom flag with timeout reset)
  • Only active when rotation === 1 (landscape mode)

Each step sends a Touch move (or Touch start/Touch end for first/last frame) for both fingers simultaneously in one colon-separated message.


Sensor Spoofing

To force the Android device into the correct screen orientation, the client sends fake sensor data every 100ms via setInterval:

Landscape orientation:

sensor>accelerate:-0.56410795_9.483055_2.0590239
sensor>rotate:0.60119915_0.19365342_0.18520845_0.756958_0.0

Portrait orientation:

sensor>accelerate:9.552746_0.4022933_1.6402799
sensor>rotate:0.56005067_-0.31531847_0.65358347_0.397583_0.0

These are hardcoded quaternion/vector values that simulate the phone being held in each orientation. The orientation is toggled by rotateScreenDC() and sent continuously by syncAccelerateDC(). Special case: com.android.chrome (Chrome browser) defaults to landscape on desktop, portrait on mobile.


Resolution Handling

  1. On page load, the browser viewport dimensions are captured
  2. Width and height are swapped so the larger dimension is always screenHeight (Android convention: nenly_screen_height = "1280", nenly_screen_width = "720")
  3. If maxResolution is enabled, the larger dimension is capped at 1280px and the other is scaled proportionally
  4. The resolution is sent to the server as res>{width}x{height} when the data channel opens
  5. resizeHandler(value) scales any browser pixel value to Android coordinates: Math.floor(value * (androidWidth / browserVideoWidth))
  6. getMousePos() handles 4 rotation/orientation combinations for coordinate axis mapping:
    • Android landscape + browser landscape → {x, y} (no swap)
    • Android landscape + browser portrait → {abs(y), abs(x - screenWidth)} (swap + flip)
    • Android portrait + browser landscape → {abs(y - screenWidth), abs(x)} (swap + flip)
    • Android portrait + browser portrait → {x, y} (no swap)

Game Config Archetypes

Each game config is fundamentally a virtual controller definition. Someone at CloudMoon plays each game, identifies where every UI element sits on the 1280x720 Android screen, and records those coordinates. The configs fall into clear archetypes:

Archetype 1: Open-World Action RPG

Games: Genshin Impact, Wuthering Waves, Honkai: Star Rail, Zenless Zone Zero

supportLeftRocker: true    → WASD movement
supportRightClick: true    → right-click camera drag
pointerLock: true          → Ctrl+M for FPS-style aim
pointerLockCoordinate      → left-click = attack, right-click = aim
~15 key bindings           → skills, menu, map, items, etc.
lClick/rClick keys         → visual indicators for pointer lock positions

The most complex configs. Players need to move, look around, and hit many different ability buttons. Pointer lock mode gives a PC-game-like feel. The lClick/rClick virtual keys allow users to reposition their pointer lock click targets.

Example (Genshin Impact): center: {200, 550}, pointerLockCoordinate: {lClick: "1060,550", rClick: "1180,620"}, 18 key bindings including J (map), M (menu), B/G (top-right UI), Z/X/C (left skills), 1/2/3 (right skills), Space (jump), Shift (sprint), and more.

Archetype 2: Battle Royale / Shooter

Games: Fortnite, PUBG Mobile, Free Fire

supportLeftRocker: true    → WASD movement
supportRightClick: true    → right-click camera drag
pointerLock: true          → FPS aiming mode
pointerLockCenter: custom  → camera center shifted right (700,360)
~15-20 key bindings        → weapons, build, items, crouch, etc.

Similar to RPGs but with a shifted pointerLockCenter — moved to {700, 360} instead of the default {640, 360} because these games have more HUD elements on the left side. Also the heaviest key binding configs because shooters have many actions (inventory slots, building pieces, etc.).

Example (Fortnite): pointerLockCenter: {700, 360}, pointerLockCoordinate: {lClick: "1125,560", rClick: "1000,290"}, 18 key bindings including Tab (inventory), 1-6 (weapon slots), Q/E/T (building), F (interact), C (crouch), and more.

Archetype 3: MOBA

Games: Mobile Legends

supportLeftRocker: true    → WASD movement
supportRightClick: true    → BUT with rightClickRadius: 1
rightClickCenter: specific → right-click taps a precise point
pointerLock: false         → no FPS mode needed
~15 key bindings           → skills, items, map

The critical detail: rightClickRadius: 1. A radius of 1 pixel means right-click isn't a camera drag at all — it's a fixed-position tap. Mobile Legends repurposes the right-click camera drag system as a single-tap auto-attack button. The rightClickCenter: {1175, 613} is the exact position of the attack button on screen.

Example (Mobile Legends): rightClickRadius: 1, rightClickCenter: {1175, 613}, rClick key with coordinate "0,0" (visual only), 15 key bindings for skills B/C/F/Q/E/R/T/G (mapped to ability buttons), 1/2/3 (item slots).

Archetype 4: Casual / Top-Down

Games: Among Us

supportLeftRocker: true    → WASD movement
supportRightClick: false   → no camera control needed
pointerLock: false         → no FPS mode
center: {104, 615}         → joystick in bottom-left corner
~4 key bindings            → use, report, kill, map

Minimal configs. The game is simple — move around and press a few action buttons. The joystick center is positioned to match where the game's native on-screen joystick sits.

Archetype 5: Portrait / Vertical Game

Games: NIKKE

supportLeftRocker: false   → no joystick
supportRightClick: false   → no camera
pointerLock: false         → no FPS mode
center: {0, 0}             → no joystick center
keyBoardDirection: 0       → PORTRAIT orientation (this is unique)
~10 key bindings           → character slots, abilities

The only configs with keyBoardDirection: 0. The keyboard overlay only renders when keyBoardDirection === rotation, so these only show overlays when the Android device is in portrait mode. The coordinate space is 720x1280 (taller than wide), so Y coordinates go much higher (e.g. "60,1165").

Archetype 6: Dual-Joystick / Fighting Game

Games: YZGAME (multiple titles sharing one config)

supportLeftRocker: true    → WASD for movement
supportRightRocker: true   → ZXCV for attack direction (UNIQUE)
supportRightClick: false   → no mouse camera
pointerLock: false         → no FPS mode
rightCenter: {1160, 480}   → right joystick center
center: {120, 480}         → left joystick center
WASD keys: coordinate "0,0" → visual only (rocker handles it)
ZXCV keys: coordinate "0,0" → visual only (rocker handles it)
arrow keys + ~12 buttons   → additional actions

The only game that uses supportRightRocker. YZGAME is a fighting game needing two simultaneous joysticks — one for movement (left), one for attack direction (right). WASD and ZXCV have coordinate: "0,0" because the rocker system handles them — they're in keyBoard purely for overlay visuals. Also has arrow keys as additional bindings with real coordinates.

Archetype 7: Pointer Lock Only (No Right-Click Camera)

Games: Sky: Children of the Light

supportLeftRocker: true    → WASD movement
supportRightClick: false   → NO right-click camera at all
pointerLock: true          → camera exclusively through pointer lock
~2 key bindings            → Space and Shift (both size: "large")

No right-click drag. Camera is exclusively through Ctrl+M pointer lock. Uses size: "large" for its key overlay buttons, making them bigger than the standard "small" size. This is the most minimal desktop config that still has pointer lock.

Archetype 8: Touch-Only / No Joystick

Games: Azur Lane, Brawl Stars (partial), Cookie Run

supportLeftRocker: true/false → varies
supportRightClick: false   → no camera
pointerLock: false         → no FPS mode
~4 key bindings            → core action buttons only

Games where most interaction is direct touch/click on the screen. The few key bindings map to frequently-used buttons to avoid constant clicking.

Shared Config Aliases

Some package names share the same config object:

  • com.roblox.client and com.roblox.client.vnggames → same Roblox config
  • com.miHoYo.bh3oversea and com.miHoYo.bh3global → same Honkai Impact config
  • com.dts.freefireth and com.dts.freefiremax → same Free Fire config
  • com.sega.persona5.the.phantomx.en and sea.com.iwplay.p5x → same Persona 5 config
  • Any game containing "YZGAME" → normalized to "YZGAME" key

The Parent Frame Bridge

The page is designed to be embedded in an iframe (the CloudMoon app's webview or the main site). The parent page communicates via postMessage:

Parent → Game iframe

window.onmessage = function(e) {
  if (e.data === 'mute')          → mutes the video element
  if (e.data === 'loud')          → unmutes the video element
  if (e.data starts with 'js>')   → forwards directly to sendDataChannelMessage()
  if (e.data starts with 'ip>')   → replies with the server IP via postMessage
}

The js> prefix is significant — the parent app can send arbitrary data channel commands into the game session. This is how the app shell can trigger game-specific actions, macros, or automation without the iframe needing specific code for it.

Game iframe → Parent

  • cloudData> messages from the server are forwarded: window.parent.postMessage(msg[1], '*')
  • Connection state changes are reported: parentPostMessage({ start_cloud_game: true/false })
  • IP requests are answered: window.parent.postMessage({ip: nenly_RealIp}, '*')

Streaming Quality & Optimization

Adaptive Jitter Buffer

adjustPlayoutDelayFromStats() dynamically adjusts the WebRTC jitter buffer target based on network conditions:

ConditionBuffer Target
Jitter < 5ms, retransmit < 5%, buffer delay < 70ms40ms (aggressive)
Jitter < 10ms, retransmit < 10%, buffer delay < 100ms100ms (moderate)
All other cases200ms (conservative)

Applied to all receivers via jitterBufferTarget, playoutDelayHint, and jitterBufferDelayHint.

Codec Preferences

The client can force codec selection via URL parameter (codec=H264 or codec=VP8) by reordering the SDP media line payload types. A codec_type parameter further prioritizes specific payload type numbers.

Bitrate Control

When bit_rate URL parameter is set, the SDP answer is modified to include:

  • x-google-max-bitrate={value} on fmtp lines
  • x-google-min-bitrate=1000 and x-google-start-bitrate=1000
  • b=AS:{value} bandwidth restriction on the video media section

Latency Indicator

The UI shows a real-time ping indicator based on dataChannelDelay (from ICE currentRoundTripTime):

  • Green (0-100ms): 4 bars
  • Green (100-250ms): 3 bars
  • Yellow (250-500ms): 2 bars
  • Red (500ms+): 1 bar

Monetization & Access Control

Ad System

  • checkAD() runs on session start — desktop only, immediately returns on mobile via this.isMobile() ||
  • Server returns: initOrientation, unlimit (premium user flag), showAD flag
  • Ad display is suppressed if the viewport is too narrow (< 600px remaining after video)
  • Portrait games on narrow screens trigger hideVertical mode (ads move to top/bottom instead of sides)
  • Ad placements vary by orientation: portrait gets double_mpu, landscape gets skyscraper (sides) or leaderboard (top/bottom)
  • Ad blocker detection runs via detectAdBlock(), re-checked every 5 seconds via setInterval
  • Ad blocker status is reported to the server: adBlock>true/false
  • Blocked ad + non-premium user = "Reduced free time"
  • Blocked ad + premium user = "Unlimited time off" (no penalty)

Quality Gating

  • changeQuality() calls api.prod.cloudmoonapp.com/phone/action
  • Quality options: HD, SD, LD, ULD
  • HD quality requires premium; non-premium users get "VIP level too low" and an upgrade upsell to shop.cloudmoonapp.com/shop
  • Token and email are passed as URL parameters to the shop

Play Time Tracking

Google Analytics events (gtag) are fired at each minute milestone from 1-30: play_for_1_min, play_for_2_min, etc. This provides session duration distribution data. Additional tracked events: socket_connecting, socket_connected, receive_offer, receive_stream, user_show_ad, user_block_ad, user_unblock_ad, and ICE connection state changes.


Per-Game Config Properties Reference

Each game config (com_*.js) can set:

PropertyTypeDefaultPurpose
supportLeftRockerbooleanfalseEnable WASD virtual joystick
supportRightRockerbooleanfalseEnable ZXCV virtual joystick
supportRightClickbooleanfalseEnable right-click camera drag
pointerLockbooleanfalseEnable Ctrl+M pointer lock (FPS mode)
center{x, y}{0, 0}Left joystick origin on Android screen
rightCenter{x, y}—Right joystick origin
rightClickCenter{x, y}{640, 360}Camera drag center point
rightClickRadiusnumber350Max camera drag distance in pixels
pointerLockCenter{x, y}{640, 360}Pointer lock camera center
pointerLockCoordinate{lClick, rClick}{lClick: "1060,550", rClick: "1180,620"}Fixed tap positions for pointer lock clicks
keyBoardDirectionnumber1Overlay orientation (0=portrait, 1=landscape)
keyBoardobject{}Key-to-coordinate mappings
keyBoard.lClickobject—Left-click coordinate override for pointer lock
keyBoard.rClickobject—Right-click coordinate override for pointer lock

keyBoard Entry Properties

PropertyTypePurpose
strstringDisplay label on overlay (e.g. "Space", "Shift", "LMB", arrow emojis)
keystringEvent key name for matching (lowercase)
locationstringCSS position override; empty string = auto-compute from coordinate
coordinatestringAndroid screen "x,y" where touch lands; "0,0" = disabled/visual-only
sizestringOverlay button size class: "small" (default) or "large"
blingbooleanIf true, plays a highlight animation (set on newly added keys)
pointerLockShowbooleanOnly show this key when pointer lock is active
pointerLockHiddenbooleanHide this key when pointer lock is active

Notable Design Decisions & Observations

The Fundamental Design: Hand-Crafted Mappings

CloudMoon's input system is a per-game, manually-created mapping table from desktop inputs to Android touch coordinates. There is no automated UI detection, no computer vision, no dynamic button recognition. Someone literally plays each game, identifies where every UI element is, and records the coordinates.

This approach is:

  • Precise — coordinates are exact pixel positions, so buttons always hit their targets
  • Fragile — any game update that moves UI elements breaks the config
  • Labor-intensive — every new game needs manual config work
  • Server-updatable — the API endpoint can push new configs without client redeployment, crucial for responding to game UI patches

Debug Surface

The following are exposed on window at startup:

window.sendDataChannelMessage  // Send arbitrary commands to Android
window.handleDCMessage         // Process fake server messages
window.pc                      // WebRTC PeerConnection object
window.datachannel             // DataChannel object

Any browser extension, injected script, or console user can send arbitrary touch/keyboard/sensor commands to the Android instance. Combined with the js> postMessage bridge from the parent frame, there are multiple entry points for external control.

eval() in Message Handler

handleDCMessage uses eval() to parse cloudData> messages from the server (line 1150 of app-methods.js):

let j = eval("(" + msg[1] + ")");

If the server-side game process can influence this data, it's a potential code execution vector in the browser context.

Circular Clamp on Right-Click Camera Drag — Likely Code Reuse

The right-click camera drag (supportRightClick) clamps the touch position to a circle of rightClickRadius pixels around the center point. This is the same Math.sqrt + Math.atan2 circular clamping math used by the WASD/ZXCV joystick rockers.

For actual joystick simulation (Brawl Stars attack aim, WASD movement), a circular clamp makes sense — real thumbsticks have circular range. But for camera drag (e.g. Roblox camera rotation), a circle is the wrong shape. The screen is a rectangle, and diagonal camera movement gets the same max distance as cardinal movement, which means you lose range in the corners for no reason. A rectangular clamp matching the screen bounds would give full range in every direction.

By contrast, the pointer lock camera system (used for FPS games) correctly uses a rectangular clamp — X and Y are clamped independently to ±(videoWidth/2) and ±(videoHeight/2).

The most likely explanation is code reuse: the joystick circle clamp was written first, and when the right-click camera drag was added, the same math was copied without considering that a camera drag has different spatial requirements than a joystick. For games like Roblox where rightClickRadius is 350 (default), this means the camera can only be dragged 350 pixels in any direction from center — even diagonally, where a rectangle would allow ~495 pixels (√(350² + 350²)).

Anti-Detection in Joystick

The left rocker applies alternating +1/-1 jitter on the secondary movement axis to avoid perfectly linear touch paths, which could be detected as bot input by anti-cheat systems. The right rocker uses random jitter (Math.random()) instead. This suggests CloudMoon is aware that games might detect automated input patterns and has taken steps to make the virtual joystick look more human.

ICE Candidate Filtering

Host ICE candidates are silently dropped (handleIceCandidate checks e.candidate.type === "host" and returns early). Only server-reflexive and relay candidates are used. This ensures all traffic routes through their infrastructure rather than attempting direct peer connections, giving CloudMoon control over the network path.

STUN Servers

Two STUN servers are configured:

  • stun:stun.nenly.cn:53006 (CloudMoon's own, non-standard port)
  • stun:stun.l.google.com:19302 (Google's public STUN)

"Nenly" appears to be CloudMoon's internal/previous company name, visible throughout the codebase in variable prefixes (nenly_screen_height, nenly_rotate_screen, etc.), URLs, and the HTML meta tag <meta name="apple-mobile-web-app-title" content="Nenly">.

iOS Autoplay Workaround

iOS Safari blocks autoplay of media elements. CloudMoon detects iOS via user agent and shows a manual "Enter Game" play button overlay. Additionally, the mouseup and touchmove handlers force-unmute both video and audio elements on the first user interaction — a belt-and-suspenders approach.

Orientation Sync

Rather than relying on the Android device's actual orientation, the client continuously spoofs accelerometer and gyroscope data at 100ms intervals. This ensures the remote device stays in the orientation the web client expects, regardless of any physical sensor state on the server's Android hardware (which, being server-rack mounted, has no meaningful physical orientation).

Context Menu Override

window.oncontextmenu is overridden to preventDefault(), preventing the browser's right-click menu so right-click can be used for camera control. This is a global override — it applies everywhere on the page, not just on the canvas.

Audio/Video Unmute on Interaction

Multiple event handlers (mouseup, touchmove, touchend) check if the video/audio elements are muted and force-unmute them. Additionally, sendDataChannelMessage itself checks if media is paused and calls .play() on any touch event. This aggressive approach ensures audio/video always plays regardless of which interaction happens first, working around various browser autoplay policies.


Sidebar Menu Controls

The floating ball menu (draggable, with click vs drag detection at 5px threshold) provides:

ControlAction
Streaming QualitySelect HD/SD/LD/ULD (API call, VIP-gated)
Full ScreenToggle via Fullscreen API
Rotate ScreenToggles isRotateScreen, swaps orientation
Mouse SensitivitySlider 0.5–2.0 (only shown when supportPointerLock is true)
Local KeyboardToggle remote/local IME via softinput>switch
Hotkeys LayoutSelect layout 1/2/3 (only shown on desktop with keyBoardDirection === 1)
Edit HotkeysToggle key editor mode
Show HotkeysToggle overlay visibility
In-game back buttonSends keycode>4 (Android BACK)
In-game Rotate ScreenSends toggled sensor data via rotateScreenDC()
Restart AppSends game>restart

API Endpoints

EndpointMethodPurpose
api.prod.cloudmoonapp.com/web/game_config?pkg={game}GETFetch game control config
api.prod.cloudmoonapp.com/web/adPOSTAd display + user tier check
api.prod.cloudmoonapp.com/phone/actionPOSTChange streaming quality
shop.cloudmoonapp.com/shopGETPremium upgrade storefront
{coordinator_host}/client/socket.ioWSSignaling server (Socket.IO)
{nenlyEsUrl}PUTAnalytics/metrics ingestion
{metricUrl}/v1/{event}POSTStructured metric events (open_page, loading_done, streaming_connected)

URL Parameters

ParameterTypePurpose
useridstringUser identifier
gamestringAndroid package name (e.g. com.roblox.client)
android_instance_idstringBase64-encoded JSON with instance metadata (includes coorUrl)
coor_urlstringCoordinator server URL override
tokenstringAuthentication token (passed to all API calls as X-User-Token header)
emailstringUser email (passed to shop for account linking)
qualitystringInitial streaming quality (SD/HD/LD/ULD)
codecstringForce video codec (H264/VP8)
codec_typenumberCodec payload type priority in SDP
bit_ratenumberMax bitrate cap (injected into SDP)
lock_mousestringEnable mouse locking behavior
always_send_movestringSend mousemove events even without button held ("true" to enable)
show_datachannel_msgstringEnable verbose data channel logging in console ("true" to enable)
stat_intervalnumberStats polling interval in ms (default 3000)
languagestringUI language (default "en")

Technology Stack

  • Frontend framework: Vue.js 2.6.12 (loaded from S3 CDN, not bundled)
  • Signaling: Socket.IO 2.4.0
  • HTTP client: Axios 0.26.0
  • Dialogs: SweetAlert2 11.26.2
  • Debugging: vConsole 3.3.4 (conditionally loaded)
  • WebRTC: adapter.js (shimming, bundled locally)
  • Logging: loglevel.js (with configurable log level)
  • Analytics: Google Analytics (G-Q30NXCCZNN)
  • Ads: vntsm (hb.vntsm.com) scan-mode
  • Module system: ES modules (no build step — files loaded directly as type="module")
  • No bundler/transpiler — all code is authored as-is, minified configs are hand-written or minimally processed

Keep reading