
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 loadingapp-methods.js— All input handling, WebRTC, socket logicapp-data.js— Reactive state/defaultsgameConfig.js— Bundled per-game control configs (imports allcom_*.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
- Page load → Vue
mounted()fires - Config fetch →
GET api.prod.cloudmoonapp.com/web/game_config?pkg={game}— falls back to bundledgameConfig.jsif the API fails. This means CloudMoon can update control schemes server-side without redeploying the frontend. - Ad check →
POST api.prod.cloudmoonapp.com/web/ad— determines ad display, free time limits, ad blocker status. Skipped entirely on mobile. - Socket.IO connect → Connects to coordinator server via websocket at
/client/socket.io - Room join →
client_registerevent with game, userId, instanceId, screen resolution - SDP offer received → Server sends WebRTC offer with video/audio
- SDP answer created → Client injects codec preferences (H264/VP8), bitrate caps (
x-google-max-bitrate,b=AS:), and RRTR extensions - ICE candidates exchanged → Host candidates are filtered out (only relay/srflx used)
- Data channel opens → Client sends
res>{width}x{height}to set Android resolution - Sensor sync starts → Fake accelerometer/gyro data sent every 100ms to force screen orientation
- Game loop starts →
requestAnimationFrameloop for virtual joystick updates - 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)
| Prefix | Format | Purpose |
|---|---|---|
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:local | Toggle 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> | hide | Dismiss soft keyboard |
softinput> | height:1 | Report 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 / off | Mute/unmute video stream |
adBlock> | true / false | Report ad blocker status |
keycode> | {code} | Android keycode (e.g. 4 = BACK) |
game> | restart | Force-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)
| Prefix | Purpose |
|---|---|
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:
| Feature | How it's disabled on mobile |
|---|---|
| Keyboard overlay | v-show="... && !isMobile()" in HTML |
| Key bindings | listenKeyDown/listenKeyUp only fire from physical keyboards |
| Virtual joysticks (WASD/ZXCV) | Only driven by keyboard key presses |
| Right-click camera | No right-click on touchscreens |
| Pointer lock (Ctrl+M) | No mouse to lock |
| Scroll → pinch zoom | No scroll wheel |
| Edit hotkeys menu | Hidden behind !isMobile() check |
| Mouse sensitivity slider | Only shown when supportPointerLock is true |
| Ads | checkAD() immediately returns on mobile |
Touch ID Allocation
Each simultaneous "finger" on the Android screen needs a unique ID. CloudMoon uses a fixed allocation scheme:
| ID | Purpose | Lifetime |
|---|---|---|
| 0-4 | Reserved (real touch events from mobile passthrough) | Dynamic |
| 5 | Left-click tap in pointer lock mode | Press & release |
| 6 | Pinch-to-zoom finger 1 | Scroll wheel gesture |
| 7 | Pinch-to-zoom finger 2 | Scroll wheel gesture |
| 8 | Camera drag (right-click or pointer lock movement) | Drag duration |
| 9 | Right-click tap in pointer lock mode | Press & release |
| 10 | Left virtual joystick (WASD) | While keys held |
| 11 | Right virtual joystick (ZXCV) | While keys held |
| 12+ | Keyboard key bindings | Assigned 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}— the0is the mouse button index (left button) - Mousemove while held →
Mouse move:0,{x},{y}— but only ifsendMoveis 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):
- Mousedown (right button) →
Touch start:8,{centerX},{centerY}— the finger "presses" at the configuredrightClickCenter(default640,360) - Mousemove while held →
Touch move:8,{centerX + deltaX},{centerY + deltaY}— the finger drags away from center, clamped torightClickRadius(default 350 pixels) - 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:
- The Touch on Android needs to lift (Touch end) — otherwise the finger stays pressed on the screen forever
- 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→ default1060,550 - Right click → Touch ID 9 tap at a fixed coordinate. Priority order:
keyBoard.rClick.coordinate(if not"0,0") →pointerLockCoordinate.rClick→ default1180,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 Drag | Pointer Lock | |
|---|---|---|
| Clamp shape | Circle (radius) | Rectangle (screen halves) |
| Configurable boundary | Yes (rightClickRadius per game) | No (always half video dimensions) |
| Reset mechanism | Mouseup (user releases button) | 200ms debounce timer (auto-detects stop) |
| Stays active until | User releases right-click | User stops moving for 200ms |
| Center point config | rightClickCenter | pointerLockCenter |
| Used for | Camera drag, attack aim, fixed tap | FPS-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:
- First key press →
Touch start:10,{cx+dx},{cy+dy}+leftRockerStart = true - Each frame while keys held →
Touch move:10,{cx+dx},{cy+dy} - 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
rightCenterfrom config instead ofcenter - Jitter uses
Math.random() < 0.5 ? -1 : 1instead 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:
- Every currently pressed key sends its
KeyUp/KeyUpCmdrelease event - Left rocker pressed keys are cleared, joystick sends
Touch end:10and resets - Right rocker pressed keys are cleared, joystick sends
Touch end:11and resets - 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
requestAnimationFramegame 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:
listenKeyDown→ W key detected →supportLeftRockeris true → routes toleftRockerPressedKeysgameLoop()→updateLeftPosition()→ moves joystick 4 units toward top of circle →Touch move:10,{153},{565 - 4}- Each frame: moves 4 more units →
Touch move:10,{153},{561},{153},{557}, etc. - After ~30 frames: reaches radius edge (120 units from center) → position clamps → continues sending same position with jitter
User presses Space:
listenKeyDown→ Space → looks upkeyBoard[" "].coordinate="1075,470"→ not"0,0"→ sendsTouch start:{id},1075,470- On keyup →
Touch end:{id},1075,470
User right-clicks and drags right:
mousedown(button 2) →supportRightClickis true, not pointer lock →Touch start:8,1085,435(at rightClickCenter)mousemove(delta +80, 0) → new position ={1085+80, 435}={1165, 435}→ distance from center = 80 < 150 (radius) → not clamped →Touch move:8,1165,435mousemove(delta +200, 0) → raw position ={1165+200, 435}={1365, 435}→ distance from center = 280 > 150 → clamped to 150 on the circle edge → sendsTouch move:8,1235,435mouseup→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):
pointerLockStartis false →Touch start:8,640,360(at pointerLockCenter default)pointerLockStart= truemoveX += 50 * sensitivity(say sensitivity = 1.0) →moveX = 50- Clamp check: 50 < 640 (half of 1280) → no clamp
Touch move:8,{640+50},{360+0}=Touch move:8,690,360- Start 200ms timer
User keeps moving right (+400 more):
- Cancel old timer, start new timer
moveX += 400→moveX = 450- Still under 640 clamp →
Touch move:8,1090,360
User stops moving for 200ms:
- Timer fires →
Touch end:8,1090,360 moveX = 0,moveY = 0,pointerLockStart = false- Next mouse movement starts fresh from center
User left-clicks (in pointer lock):
- Check
keyBoard.lClick.coordinate="0,0"→ skip - Check
pointerLockCoordinate.lClick="1060,550"→ use this Touch start:5,1060,550→Touch end:5,1060,550- This taps the attack button in Genshin's UI
User drags the LMB overlay icon in the editor to position 800,400:
- Now
keyBoard.lClick.coordinate="800,400"(non-zero) - Next left-click: check
keyBoard.lClick.coordinate="800,400"→ not"0,0"→ uses this instead 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:
listenKeyDown(W)→supportLeftRockertrue → routes toleftRockerPressedKeys.w = truelistenKeyDown(Z)→supportRightRockertrue → routes torightRockerPressedKeys.z = truegameLoop()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}
- 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:
listenKeyDown(ArrowUp)→ not W/A/S/D → not Z/X/C/V → falls through to normal key handling- Looks up
keyBoard.arrowup.coordinate="333,515"→ not"0,0"→ sendsTouch start:{id},333,515 - This is a separate button in the game — arrow keys are real coordinate taps, not joystick directions
Summary: How Config Flags Change Behavior
| Scenario | Config | Mouse Left-Click | Mouse Right-Click | WASD Keys | Pointer Lock |
|---|---|---|---|---|---|
| RPG (Genshin) | rocker + rClick + pLock | Mouse down passthrough | Touch 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 passthrough | Touch ID 8 attack aim (radius 150) | Left joystick (Touch ID 10) | N/A (pointerLock: false) |
| MOBA (Mobile Legends) | rocker + rClick(1) | Mouse down passthrough | Touch ID 8 fixed tap (radius 1 = no drag) | Left joystick (Touch ID 10) | N/A |
| Fighting (YZGAME) | both rockers, no rClick | Touch start (no right-click → touch protocol) | Nothing (disabled) | Left joystick | N/A |
| Casual (Among Us) | rocker only | Touch start (no right-click → touch protocol) | Nothing (disabled) | Left joystick | N/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:
- WASD/ZXCV visual indicators — When rockers are enabled, the rocker system handles the actual continuous touch drag. The WASD keys exist in
keyBoardwith"0,0"coordinate purely so the overlay shows labeled buttons where the joystick is. - 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. - Disabled pointer lock clicks —
lClick/rClickdefault to"0,0", meaning pointer lock mode usespointerLockCoordinatevalues 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 usepointerLockCoordinate - When coordinate is non-zero → the mousedown/mouseup handler checks
keyBoard.lClick.coordinate/keyBoard.rClick.coordinatefirst 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:
listenKeyDown(e)fires from thedocumentkeydown listener- Guards skip if: input/textarea is focused, edit mode is active, key is F1-F12, or key is already held (prevents browser key repeat)
- Ctrl+M intercept — if Ctrl+M and
pointerLockis enabled, toggles pointer lock instead of sending a key event - Key repeat prevention —
keyDownMap[e.key] = trueon first press; subsequent keydown events for the same key are ignored - Rocker routing — if the key is W/A/S/D and
supportLeftRockeris on, it goes intoleftRockerPressedKeys(handled by the game loop, not here). Same for Z/X/C/V withsupportRightRocker. - Visual feedback — CSS class
keyboard-key-pressis added to the overlay button (found via.keyboard-key-{keycode}selector) - Touch generation — looks up
currentGameConfig.keyBoard[key].coordinate, and if not"0,0", sendstouch>Touch start:{id},{x},{y}
On keyup (listenKeyUp):
keyDownMap[e.key] = false- Modifier keys (Alt, Ctrl, Shift, Meta) get explicit
KeyUpCmdmessages if their flag is inconsistent - If it was a rocker key and all rocker keys are released, sends
Touch endfor the joystick - Removes
keyboard-key-pressCSS class - 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 activepointerLockHidden: 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 abling: trueanimation flag. - Drag keys — Pointer drag recalculates both the CSS position and the
coordinatefrom the drag position mapped to 1280x720 space - Drag the joystick center — Repositions the left rocker's
centerorigin 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
supportLeftRockeris 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
setTimeoutdelays of 2.5ms * step index - Throttled: one zoom event per 300ms (
this.zoomflag 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
- On page load, the browser viewport dimensions are captured
- Width and height are swapped so the larger dimension is always
screenHeight(Android convention:nenly_screen_height = "1280",nenly_screen_width = "720") - If
maxResolutionis enabled, the larger dimension is capped at 1280px and the other is scaled proportionally - The resolution is sent to the server as
res>{width}x{height}when the data channel opens resizeHandler(value)scales any browser pixel value to Android coordinates:Math.floor(value * (androidWidth / browserVideoWidth))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)
- Android landscape + browser landscape →
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.clientandcom.roblox.client.vnggames→ same Roblox configcom.miHoYo.bh3overseaandcom.miHoYo.bh3global→ same Honkai Impact configcom.dts.freefirethandcom.dts.freefiremax→ same Free Fire configcom.sega.persona5.the.phantomx.enandsea.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:
| Condition | Buffer Target |
|---|---|
| Jitter < 5ms, retransmit < 5%, buffer delay < 70ms | 40ms (aggressive) |
| Jitter < 10ms, retransmit < 10%, buffer delay < 100ms | 100ms (moderate) |
| All other cases | 200ms (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 linesx-google-min-bitrate=1000andx-google-start-bitrate=1000b=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 viathis.isMobile() ||- Server returns:
initOrientation,unlimit(premium user flag),showADflag - Ad display is suppressed if the viewport is too narrow (< 600px remaining after video)
- Portrait games on narrow screens trigger
hideVerticalmode (ads move to top/bottom instead of sides) - Ad placements vary by orientation: portrait gets
double_mpu, landscape getsskyscraper(sides) orleaderboard(top/bottom) - Ad blocker detection runs via
detectAdBlock(), re-checked every 5 seconds viasetInterval - 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()callsapi.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 toshop.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:
| Property | Type | Default | Purpose |
|---|---|---|---|
supportLeftRocker | boolean | false | Enable WASD virtual joystick |
supportRightRocker | boolean | false | Enable ZXCV virtual joystick |
supportRightClick | boolean | false | Enable right-click camera drag |
pointerLock | boolean | false | Enable 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 |
rightClickRadius | number | 350 | Max 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 |
keyBoardDirection | number | 1 | Overlay orientation (0=portrait, 1=landscape) |
keyBoard | object | {} | Key-to-coordinate mappings |
keyBoard.lClick | object | — | Left-click coordinate override for pointer lock |
keyBoard.rClick | object | — | Right-click coordinate override for pointer lock |
keyBoard Entry Properties
| Property | Type | Purpose |
|---|---|---|
str | string | Display label on overlay (e.g. "Space", "Shift", "LMB", arrow emojis) |
key | string | Event key name for matching (lowercase) |
location | string | CSS position override; empty string = auto-compute from coordinate |
coordinate | string | Android screen "x,y" where touch lands; "0,0" = disabled/visual-only |
size | string | Overlay button size class: "small" (default) or "large" |
bling | boolean | If true, plays a highlight animation (set on newly added keys) |
pointerLockShow | boolean | Only show this key when pointer lock is active |
pointerLockHidden | boolean | Hide 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:
| Control | Action |
|---|---|
| Streaming Quality | Select HD/SD/LD/ULD (API call, VIP-gated) |
| Full Screen | Toggle via Fullscreen API |
| Rotate Screen | Toggles isRotateScreen, swaps orientation |
| Mouse Sensitivity | Slider 0.5–2.0 (only shown when supportPointerLock is true) |
| Local Keyboard | Toggle remote/local IME via softinput>switch |
| Hotkeys Layout | Select layout 1/2/3 (only shown on desktop with keyBoardDirection === 1) |
| Edit Hotkeys | Toggle key editor mode |
| Show Hotkeys | Toggle overlay visibility |
| In-game back button | Sends keycode>4 (Android BACK) |
| In-game Rotate Screen | Sends toggled sensor data via rotateScreenDC() |
| Restart App | Sends game>restart |
API Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
api.prod.cloudmoonapp.com/web/game_config?pkg={game} | GET | Fetch game control config |
api.prod.cloudmoonapp.com/web/ad | POST | Ad display + user tier check |
api.prod.cloudmoonapp.com/phone/action | POST | Change streaming quality |
shop.cloudmoonapp.com/shop | GET | Premium upgrade storefront |
{coordinator_host}/client/socket.io | WS | Signaling server (Socket.IO) |
{nenlyEsUrl} | PUT | Analytics/metrics ingestion |
{metricUrl}/v1/{event} | POST | Structured metric events (open_page, loading_done, streaming_connected) |
URL Parameters
| Parameter | Type | Purpose |
|---|---|---|
userid | string | User identifier |
game | string | Android package name (e.g. com.roblox.client) |
android_instance_id | string | Base64-encoded JSON with instance metadata (includes coorUrl) |
coor_url | string | Coordinator server URL override |
token | string | Authentication token (passed to all API calls as X-User-Token header) |
email | string | User email (passed to shop for account linking) |
quality | string | Initial streaming quality (SD/HD/LD/ULD) |
codec | string | Force video codec (H264/VP8) |
codec_type | number | Codec payload type priority in SDP |
bit_rate | number | Max bitrate cap (injected into SDP) |
lock_mouse | string | Enable mouse locking behavior |
always_send_move | string | Send mousemove events even without button held ("true" to enable) |
show_datachannel_msg | string | Enable verbose data channel logging in console ("true" to enable) |
stat_interval | number | Stats polling interval in ms (default 3000) |
language | string | UI 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
