CloudMoon client teardown: how the cloud gaming stack works

Reading CloudMoon's Vue client end to end: the Socket.IO handshake, the WebRTC setup, the adaptive-quality loop, and every endpoint the code talks to.

Neo-brutalist cover reading CloudMoon Client Teardown, tagged Socket.IO, DataChannel and Vue.js.

IMPORTANT: This document analyzes ONLY the client-side code found in this repository. Sections marked with πŸ” are direct code analysis. Sections marked with πŸ’‘ are educated guesses based on client behavior.


Table of Contents

  1. What We Have (Client Code)
  2. Client Architecture
  3. Communication Protocol
  4. WebRTC Implementation
  5. DataChannel Message Protocol
  6. Input Handling System
  7. Game Configuration System
  8. CloudMoon Infrastructure

What We Have (Client Code)

πŸ” Files in This Repository

/run-site/
β”œβ”€β”€ index.html              # Main HTML entry point
β”œβ”€β”€ js/
β”‚   β”œβ”€β”€ app.js              # Vue instance initialization (149 lines)
β”‚   β”œβ”€β”€ app-data.js         # Application state (148 lines)
β”‚   β”œβ”€β”€ app-methods.js      # Core logic (1973 lines) ⭐ MAIN FILE
β”‚   β”œβ”€β”€ app-utils.js        # Utility functions
β”‚   β”œβ”€β”€ app-component.js    # Vue components (ad system)
β”‚   β”œβ”€β”€ gameConfig.js       # Game registry (79 lines)
β”‚   β”œβ”€β”€ keyToKeyCodeMap.js  # Keyboard mapping
β”‚   β”œβ”€β”€ adapter-latest.js   # WebRTC adapter polyfill
β”‚   β”œβ”€β”€ loading.js          # Loading screen
β”‚   β”œβ”€β”€ loglevel.min.js     # Logging
β”‚   └── com_*.js (28 files) # Per-game configurations
β”œβ”€β”€ css/
β”‚   └── main.css            # Styling
└── images/
    β”œβ”€β”€ favicon.png
    β”œβ”€β”€ logo.png
    └── send.png

πŸ” Tech Stack (Client-Side)

From index.html:

<!-- Core Framework -->
<script src="https://s3.ap-southeast-1.amazonaws.com/cdn.cloudbatata.com/ajax/libs/vue/2.6.12/vue.min.js"></script>
<script src="https://s3.ap-southeast-1.amazonaws.com/cdn.cloudbatata.com/ajax/libs/socket.io/2.4.0/socket.io.js"></script>
<script src="https://s3.ap-southeast-1.amazonaws.com/cdn.cloudbatata.com/ajax/libs/axios/0.26.0/axios.min.js"></script>
<script src="https://s3.ap-southeast-1.amazonaws.com/cdn.cloudbatata.com/ajax/libs/vConsole/3.3.4/vconsole.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.26.2/dist/sweetalert2.all.min.js"></script>

<!-- Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q30NXCCZNN"></script>

<!-- Ad Network -->
<script src="https://hb.vntsm.com/v4/live/vms/sites/cloudmoonapp.com/index.js" data-mode="scan"></script>

Summary:

  • Vue.js 2.6.12 (reactive UI)
  • Socket.IO 2.4.0 (WebSocket signaling)
  • Axios 0.26.0 (HTTP client)
  • WebRTC (native browser API)
  • SweetAlert2 (dialogs)
  • vConsole (mobile debugging)

Client Architecture

πŸ” Component Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Vue.js App                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   Socket.IO  β”‚  β”‚   WebRTC     β”‚  β”‚  Input       β”‚ β”‚
β”‚  β”‚   Client     β”‚  β”‚PeerConnectionβ”‚  β”‚  Capture     β”‚ β”‚
β”‚  β”‚              β”‚  β”‚              β”‚  β”‚              β”‚ β”‚
β”‚  β”‚ - Signaling  β”‚  β”‚ - Video recv β”‚  β”‚ - Touch      β”‚ β”‚
β”‚  β”‚ - Room mgmt  β”‚  β”‚ - DataChannelβ”‚  β”‚ - Keyboard   β”‚ β”‚
β”‚  β”‚ - ICE relay  β”‚  β”‚ - Stats      β”‚  β”‚ - Mouse      β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚                  β”‚                  β”‚
         β–Ό                  β–Ό                  β–Ό
    Signaling          Video Stream        Input Commands
     Server             (H.264/VP8)       (DataChannel)

πŸ” State Management (app-data.js)

// app-data.js - Complete application state
const params = new URLSearchParams(window.location.search);

export const AppData = {
    // Connection params
    token: params.get("token"),
    email: params.get("email"),
    game: params.get("game"),                    // e.g., "com.roblox.client"
    userId: params.get("userid"),
    androidInstanceId: params.get("android_instance_id"),  // Base64 encoded
    quality: params.get("quality"),              // HD, SD, LD, ULD
    codec: params.get("codec"),                  // H264 or VP8

    // WebRTC
    pc: null,                                    // RTCPeerConnection
    dataChannel: null,                           // RTCDataChannel
    remoteStream: null,

    // Video dimensions
    screenWidth: 720,                            // Default
    screenHeight: 1280,
    videoWidth: 720,
    videoHeight: 1280,

    // Android instance info (decoded from base64)
    androidInstance: {},                         // { androidIp, coorUrl, ... }

    // Game config
    currentGameConfig: {},                       // Loaded per-game config
    supportPointerLock: false,
    supportLeftRocker: false,
    supportRightRocker: false,

    // UI state
    showKeyboard: true,
    isEditKeyboard: false,
    cursorLock: false,
    openMenu: false,

    // Performance
    dataChannelDelay: 0,                        // RTT in ms
    videoStats: null,

    // Kubernetes pod info
    podName: "",
    podLocale: 0,

    // ... many more properties
};

Communication Protocol

πŸ” Phase 1: Initial Connection

From app-methods.js lines 873-898:

connectSocket() {
    let socketUrl = this.getSocketUrl(); // Returns coordinator URL

    this.socket = io(socketUrl, {
        reconnection: true,
        reconnectionAttempts: 5,
        reconnectionDelay: 1000,
        path: '/client/socket.io',      // WebSocket path
        pingInterval: 10000,
        pingTimeout: 20000,
        transports: ['websocket']       // WebSocket only, no polling
    });

    this.socketBind(); // Setup event handlers
}

Socket URL determination (app-methods.js lines 695-719):

getSocketUrl() {
    const params = new URLSearchParams(window.location.search);

    // Try to decode android_instance_id (base64 JSON)
    if (params.has("android_instance_id")) {
        this.androidInstanceId = params.get("android_instance_id");

        try {
            this.androidInstance = JSON.parse(atob(this.androidInstanceId));
            // androidInstance = { coorUrl, androidIp, ... }

            if (this.androidInstance.coorUrl) {
                return this.androidInstance.coorUrl;
            }
        } catch (e) {}
    }

    // Fallback to current hostname
    return `https://${window.location.hostname}`;
}

πŸ” Phase 2: Socket.IO Signaling

Event handlers (app-methods.js lines 900-1000):

socketBind() {
    // Server says hello
    this.socket.on('hello_server', () => {
        setTimeout(() => this.joinRoom(), 1000);
    });

    // Connected to signaling server
    this.socket.on('connect', () => {
        gtag('event', 'socket_connected');
    });

    // Server ready, send registration
    this.socket.on('action_done2', (data) => {
        this.socket.emit('register', {
            roomid: data.signalingRoomId,
            clientid: '1111111'
        });
    });

    // WebRTC messages
    this.socket.on('msg', (msg) => {
        switch(msg.type) {
            case 'offer':
                // Receive SDP offer from game server
                this.pc.setRemoteDescription(new RTCSessionDescription(msg));
                this.createAnswer();
                break;

            case 'candidate':
                // Receive ICE candidate
                this.pc.addIceCandidate(new RTCIceCandidate({
                    sdpMLineIndex: msg.label,
                    candidate: msg.candidate
                }));
                break;
        }
    });

    // Session status
    this.socket.on('getout', (data) => {
        data = JSON.parse(data);

        if (data.errorCode === 10) {
            // Success! Stream connected
            this.streamingOn = true;
            this.socket.close();
        } else if (data.errorCode === 1) {
            // Server busy
        }
        // ... more error codes
    });
}

Client registration (app-methods.js lines 1010-1035):

joinRoom() {
    let data = {
        screenRes: this.screenWidth + 'x' + this.screenHeight,
        game: this.game,                    // Package name
        userId: this.userId,
        slotId: this.userId + '@' + this.game,
        instanceId: this.androidInstanceId   // Base64 encoded
    };

    this.socket.emit('client_register', data);

    // Timeout if no response
    this.joinRoomTimer = setTimeout(() => {
        if (this.joinRoomCount > 2) {
            swal.fire({ title: 'Connection failed' });
        } else {
            this.joinRoomCount++;
            this.joinRoom(); // Retry
        }
    }, 3000);
}

πŸ” Phase 3: WebRTC Setup

Create peer connection (app-methods.js lines 1251-1289):

createPeerConnection() {
    // ACTUAL ICE servers from their code
    this.pc = new RTCPeerConnection({
        iceServers: [
            { urls: 'stun:stun.nenly.cn:53006' },         // Their STUN
            { urls: 'stun:stun.l.google.com:19302' }      // Google STUN
        ],
        bundlePolicy: 'max-bundle',
        rtcpMuxPolicy: 'require'
    });

    // Event handlers
    this.pc.onicecandidate = this.handleIceCandidate;
    this.pc.ontrack = this.handleTrack;
    this.pc.oniceconnectionstatechange = function() {
        gtag('event', `ice_connection_state_${this.pc.iceConnectionState}`);
    };

    // Create data channel for input
    this.createDataChannel();
}

DataChannel creation (app-methods.js lines 1037-1066):

createDataChannel() {
    let channel = this.pc.createDataChannel('config', {
        ordered: true,
        reliable: true
    });

    channel.onopen = () => {
        // Send initial resolution
        this.dataChannel.send('res>' + this.screenWidth + 'x' + this.screenHeight);

        // Start statistics polling
        this.getStats();
    };

    channel.onmessage = (event) => {
        this.handleDCMessage(event.data);
    };

    channel.onclose = () => {
        // Connection lost
        this.pc.close();
        this.streamingOn = false;
    };

    this.dataChannel = channel;
}

Create answer (app-methods.js lines 1349-1377):

createAnswer() {
    this.pc.createAnswer().then(answer => {
        // Optional bitrate constraint
        if (this.bitRate > 0) {
            let lines = answer.sdp.split('\r\n');
            lines.forEach((line, idx) => {
                if (/^a=fmtp:\d*/.test(line)) {
                    lines[idx] = line +
                        `;x-google-max-bitrate=${this.bitRate}` +
                        `;x-google-min-bitrate=1000`;
                }
            });
            answer.sdp = lines.join('\r\n');
        }

        this.pc.setLocalDescription(answer);
        this.sendMessage(answer); // Send via Socket.IO
    });
}

WebRTC Implementation

πŸ” Video Track Handling

From app-methods.js lines 1203-1236:

handleTrack(event) {
    event.track.onunmute = () => {
        if (event.track.kind === 'video') {
            // Bind video stream to <video> element
            this.$refs.remoteVideo.srcObject = event.streams[0];
            this.remoteStream = event.streams[0];

            // Get receivers for jitter buffer tuning
            this.receivers = this.pc.getReceivers();
        } else if (event.track.kind === 'audio') {
            this.$refs.remoteAudio.srcObject = event.streams[0];
        }
    };
}

πŸ” Statistics & Adaptive Quality

From app-methods.js lines 762-792:

getStats() {
    this.pc.getStats().then(stats => {
        stats.forEach(report => {
            // Video stats
            if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
                this.videoStats = report;

                // Adjust jitter buffer based on network conditions
                this.adjustPlayoutDelayFromStats(report);
            }

            // RTT (latency)
            if (report.type === 'candidate-pair' && report.nominated) {
                this.dataChannelDelay = 1000 * report.currentRoundTripTime;
                // Cap at 999ms
                if (this.dataChannelDelay > 999) {
                    this.dataChannelDelay = 999;
                }
            }
        });
    });

    setTimeout(() => this.getStats(), 1000); // Poll every second
}

Adaptive jitter buffer (app-methods.js lines 742-760):

adjustPlayoutDelayFromStats(stats) {
    const jitter = stats.jitter ?? 1;
    const packetLoss = (stats.retransmittedPacketsReceived ?? 0) /
                       (stats.packetsReceived ?? 1);
    const avgJitterDelay = (stats.jitterBufferMinimumDelay ?? 0) /
                           (stats.jitterBufferEmittedCount ?? 1) * 1000;

    let targetDelay = 0.2; // Default 200ms

    // Ultra low latency
    if (jitter < 0.005 && packetLoss < 0.05 && avgJitterDelay < 70) {
        targetDelay = 0.04; // 40ms
    }
    // Low latency
    else if (jitter < 0.01 && packetLoss < 0.1 && avgJitterDelay < 100) {
        targetDelay = 0.1; // 100ms
    }

    // Apply to all receivers
    for (const receiver of this.pc.getReceivers()) {
        if (receiver.jitterBufferTarget !== undefined) {
            receiver.jitterBufferTarget = targetDelay;
        }
    }
}

DataChannel Message Protocol

πŸ” Message Format

All messages: {type}>{data}

πŸ” Client β†’ Server Messages

From actual code usage:

MessageExampleSource Code
res>res>1280x720app-methods.js:1051
touch>touch>Touch start:10,640,360app-methods.js:1721
touch>touch>Mouse down:0,640,360app-methods.js:1546
keyboard>keyboard>66,0Not directly used
keycode>keycode>4index.html (BACK button)
sensor>sensor>accelerate:9.55_0.40_1.64app-methods.js:1333
softinput>softinput>switch:remoteapp-methods.js:554
softinput>softinput>text_change:0:0,999,Helloapp-methods.js:652
softinput>softinput>action:4app-methods.js:637
game>game>restartindex.html (Restart button)
stats>stats>app-methods.js:737 (request stats)
adBlock>adBlock>trueapp-methods.js:116

πŸ” Server β†’ Client Messages

From handleDCMessage (app-methods.js lines 1087-1166):

handleDCMessage(msg) {
    msg = msg.split('>');
    let type = msg[0];
    let data = msg[1];

    switch(type) {
        case 'rotation':
            // Screen orientation (0=portrait, 1=landscape)
            this.rotation = parseInt(data);
            this.rotateHandler();
            break;

        case 'keyboard':
            // show, hide, verify
            if (data.startsWith('show')) {
                this.inputTextShow = true;
                this.$refs.input.focus();
            }
            break;

        case 'stats':
            // Performance metrics
            // Format: "bwe:12345,rtt:50,fps:60,..."
            let metrics = data.split(',');
            // Parse and update UI
            break;

        case 'cloudData':
            // Custom game data (JSON)
            let gameData = JSON.parse(data);
            if (gameData.loading_done) {
                this.loadingDoneTime = Date.now();
            }
            break;

        case 'pod_name':
            // Kubernetes pod identifier
            this.podName = data;
            break;

        case 'locale':
            // Datacenter region
            this.podLocale = data;
            break;

        case 'kickout':
            // Session terminated
            this.getout = true;
            this.socket.close();
            break;

        case 'hide_cursor':
            // 0 or 1
            this.cursorHide = (data === '1');
            break;

        case 'touchState':
            // true or false
            this.enableTouch = (data === 'true');
            break;
    }
}

Input Handling System

πŸ” Coordinate Transformation

Critical for mapping browser coordinates to Android (app-methods.js lines 1690-1718):

getMousePos(pageX, pageY) {
    // 1. Convert to container-relative
    let rect = this.$refs.containerDiv.getBoundingClientRect();
    pageX -= rect.left;
    pageY -= rect.top;

    // 2. Center adjustment
    let videoW = this.direction === GAME_VIEW_PORTRAIT ?
                 this.videoWidth : this.videoHeight;
    let videoH = this.direction === GAME_VIEW_PORTRAIT ?
                 this.videoHeight : this.videoWidth;

    pageX -= (containerWidth - videoW) / 2;
    pageY -= (containerHeight - videoH) / 2;

    // 3. Scale to Android resolution
    let scale = this.screenWidth / this.$refs.remoteVideo.offsetWidth;
    pageX = Math.floor(pageX * scale);
    pageY = Math.floor(pageY * scale);

    // 4. Handle rotation
    if (this.rotation === ANDROID_LANDSCAPE &&
        this.direction === GAME_VIEW_PORTRAIT) {
        // Rotate coordinates 90 degrees
        return {
            x: Math.abs(pageY),
            y: Math.abs(pageX - this.screenWidth)
        };
    }

    return { x: pageX, y: pageY };
}

πŸ” Touch Input

From app-methods.js lines 1720-1739:

touchstart(event) {
    let msg = 'touch>Touch start' + this.getChangedTouchesString(event);
    this.sendDataChannelMessage(msg);
}

touchmove(event) {
    let msg = 'touch>Touch move' + this.getChangedTouchesString(event);
    this.sendDataChannelMessage(msg);
}

touchend(event) {
    let msg = 'touch>Touch end' + this.getChangedTouchesString(event);
    this.sendDataChannelMessage(msg);
}

getChangedTouchesString(event) {
    let result = '';
    for (let i = 0; i < event.changedTouches.length; i++) {
        let touch = event.changedTouches[i];
        let pos = this.getMousePos(touch.pageX, touch.pageY);
        let id = this.getTouchCounter(touch.identifier);

        // Format: :id,x,y
        result += `:${id},${pos.x},${pos.y}`;
    }
    return result;
    // Example: "touch>Touch start:10,640,360:11,200,300"
}

// Persistent touch ID mapping
getTouchCounter(identifier) {
    if (!this.touchIdMap.has(identifier)) {
        this.touchIdMap.set(identifier, this.touchCounter++);
    }
    return this.touchIdMap.get(identifier);
}

πŸ” Mouse Input

Normal mouse (app-methods.js lines 1505-1549):

mousedown(event) {
    const canvas = document.getElementById('canvasCoc');
    const isPointerLocked = document.pointerLockElement === canvas;

    if (isPointerLocked) {
        // Pointer lock mode (FPS games)
        if (event.button === 0) { // Left click
            let coord = '1060,550'; // Fixed position
            this.sendDataChannelMessage(`touch>Touch start:5,${coord}`);
        }
    } else {
        // Normal mode - send actual coordinates
        this.sendMove = true;
        let msg = `touch>Mouse down:${event.button},` +
                  `${this.resizeHandler(event.offsetX)},` +
                  `${this.resizeHandler(event.offsetY)}`;
        this.sendDataChannelMessage(msg);
    }
}

Pointer lock mode (app-methods.js lines 1551-1585):

mousemove(event) {
    const isPointerLocked = document.pointerLockElement === canvas;

    if (isPointerLocked) {
        // Accumulate movement deltas
        this.moveX += event.movementX * this.mouseSensitivity;
        this.moveY += event.movementY * this.mouseSensitivity;

        // Clamp to screen bounds
        let maxX = this.$refs.remoteVideo.offsetWidth / 2;
        let maxY = this.$refs.remoteVideo.offsetHeight / 2;
        this.moveX = Math.max(-maxX, Math.min(maxX, this.moveX));
        this.moveY = Math.max(-maxY, Math.min(maxY, this.moveY));

        let center = { x: 640, y: 360 };
        let dx = this.resizeHandler(this.moveX);
        let dy = this.resizeHandler(this.moveY);

        this.sendDataChannelMessage(
            `touch>Touch move:8,${center.x + dx},${center.y + dy}`
        );

        // Auto-release after 200ms of no movement
        clearTimeout(this.resetMoveTimer);
        this.resetMoveTimer = setTimeout(() => {
            this.sendDataChannelMessage(
                `touch>Touch end:8,${center.x + dx},${center.y + dy}`
            );
            this.moveX = 0;
            this.moveY = 0;
        }, 200);
    }
}

πŸ” Virtual Joystick (WASD)

Continuous update loop (app-methods.js lines 494-550):

gameLoop() {
    this.updateLeftPosition();  // WASD joystick
    this.updateRightPosition(); // ZXCV joystick (if game supports)
    requestAnimationFrame(this.gameLoop);
}

updateLeftPosition() {
    if (!this.supportLeftRocker) return;

    let dx = 0, dy = 0;
    let step = 4; // Pixels per frame

    // Calculate movement vector based on pressed keys
    if (this.leftRockerPressedKeys.w) dy -= step;
    if (this.leftRockerPressedKeys.s) dy += step;
    if (this.leftRockerPressedKeys.a) dx -= step;
    if (this.leftRockerPressedKeys.d) dx += step;

    // Update accumulated position
    this.leftRockerPosition.x += dx;
    this.leftRockerPosition.y += dy;

    // Clamp to circular boundary (radius = 120px)
    let radius = 120;
    let distance = Math.sqrt(
        this.leftRockerPosition.x ** 2 +
        this.leftRockerPosition.y ** 2
    );

    if (distance > radius) {
        let angle = Math.atan2(this.leftRockerPosition.y,
                               this.leftRockerPosition.x);
        this.leftRockerPosition.x = radius * Math.cos(angle);
        this.leftRockerPosition.y = radius * Math.sin(angle);
    }

    // Send touch events if keys are pressed
    if (Object.keys(this.leftRockerPressedKeys).length > 0) {
        if (!this.leftRockerStart) {
            // Initial touch
            let msg = `touch>Touch start:10,` +
                     `${this.center.x + this.leftRockerPosition.x},` +
                     `${this.center.y + this.leftRockerPosition.y}`;
            this.sendDataChannelMessage(msg);
            this.leftRockerStart = true;
        }

        // Continuous movement (60fps)
        let msg = `touch>Touch move:10,` +
                 `${this.center.x + Math.floor(this.leftRockerPosition.x)},` +
                 `${this.center.y + Math.floor(this.leftRockerPosition.y)}`;
        this.sendDataChannelMessage(msg);
    }
}

πŸ” Keyboard Handling

Key down (app-methods.js lines 1825-1857):

listenKeyDown(event) {
    event.preventDefault();

    // Ctrl+M toggles pointer lock
    if (event.ctrlKey && event.keyCode === 77) {
        if (this.currentGameConfig?.pointerLock) {
            this.pointerLock();
        }
        return;
    }

    if (this.keyDownMap[event.key]) return; // Already pressed
    this.keyDownMap[event.key] = true;

    let key = event.key.toLowerCase();

    // Virtual joystick keys
    if (this.supportLeftRocker && ['w','a','s','d'].includes(key)) {
        this.leftRockerPressedKeys[key] = true;
        return; // Handled by gameLoop
    }

    // Game-specific key binding
    let config = this.currentGameConfig.keyBoard?.[key];
    if (config && config.coordinate !== '0,0') {
        let touchId = this.getTouchCounter(key);
        this.sendDataChannelMessage(
            `touch>Touch start:${touchId},${config.coordinate}`
        );

        // Visual feedback
        document.querySelector(`.keyboard-key-${this.keyToKeyCode(key)}`)
            ?.classList.add('keyboard-key-press');
    }
}

Game Configuration System

πŸ” Configuration Structure

Example: Roblox (com_roblox_client.js):

const comRobloxClientConfig = {
    supportLeftRocker: true,      // Enable WASD joystick
    supportRightClick: true,       // Enable right-click camera
    pointerLock: false,            // Disable FPS mouse lock

    center: { x: 162, y: 575 },    // Left joystick center

    keyBoardDirection: 1,          // 0=portrait, 1=landscape

    keyBoard: {
        ' ': {
            str: 'Space',
            key: 'space',
            coordinate: '1142,575',  // Android screen coords
            size: 'small'
        },
        'shift': {
            str: 'Shift',
            key: 'shift',
            coordinate: '1050,575',
            size: 'small'
        },
        'escape': {
            str: 'Esc',
            key: 'escape',
            coordinate: '45,45',
            size: 'small'
        }
    }
};

πŸ” Configuration Loading

From app.js lines 50-72:

async mounted() {
    // Try to fetch remote config first
    const response = await fetch(
        `https://api.prod.cloudmoonapp.com/web/game_config?pkg=${this.checkGame}`
    );

    let config = {};
    if (response.code === 0) {
        config = response.data.config; // Use server config
    } else {
        // Fallback to local bundled config
        config = this.gameConfig[this.checkGame] ?? {};
    }

    this.currentGameConfig = config;
    this.supportPointerLock = config?.pointerLock ?? false;
    this.supportLeftRocker = config?.supportLeftRocker ?? false;
    this.center = config?.center ?? { x: 0, y: 0 };

    // Load user customizations from localStorage
    this.loadKeyboardLayout();
}

πŸ” Custom Layout Persistence

From app-methods.js lines 391-440:

saveKeyboardLayout() {
    localStorage.setItem(
        `keyboardLayout:${this.selectLayout}:${this.checkGame}:center`,
        JSON.stringify(this.currentGameConfig.center)
    );

    localStorage.setItem(
        `keyboardLayout:${this.selectLayout}:${this.checkGame}:newKeyboard`,
        JSON.stringify(this.currentGameConfig.keyBoard)
    );
}

loadKeyboardLayout() {
    // Load saved keyboard (3 layouts: layout1, layout2, layout3)
    let saved = localStorage.getItem(
        `keyboardLayout:${this.selectLayout}:${this.checkGame}:newKeyboard`
    );

    if (saved) {
        this.currentGameConfig.keyBoard = JSON.parse(saved);
    } else if (this.defaultKeyboard) {
        // Use default from config
        this.currentGameConfig.keyBoard =
            JSON.parse(JSON.stringify(this.defaultKeyboard));
    }
}

πŸ” Supported Games (28 total)

From gameConfig.js:

export default {
    "com.miHoYo.GenshinImpact": comMiHoYoGenshinImpactConfig,
    "com.roblox.client": comRobloxClientConfig,
    "com.HoYoverse.hkrpgoversea": comHoYoverseHkrpgoverseaConfig,
    "com.kurogame.wutheringwaves.global": comKurogameWutheringwavesGlobalConfig,
    "com.HoYoverse.Nap": comHoYoverseNapConfig,
    "com.devsisters.ck": comDevsistersCkClientConfig,
    "com.netease.idv.googleplay": comNeteaseIdvGoogleplayConfig,
    "com.infoldgames.infinitynikkien": comInfoldgamesInfinitynikkienConfig,
    "com.papegames.lysk.en": comPapegamesLyskEnConfig,
    "com.netmarble.sololv": comNetmarbleSololvConfig,
    "com.proximabeta.nikke": comProximabetaNikkeClientConfig,
    "com.miHoYo.bh3oversea": comMiHoYoBh3overseaConfig,
    "com.kurogame.gplay.punishing.grayraven.en": comKurogameGplayPunishingGrayravenEnConfig,
    "com.netease.dfjssea": comNeteaseDfjsseaConfig,
    "jp.konami.pesam": jpKonamiPesamConfig,
    "com.epicgames.fortnite": comEpicgamesFortniteConfig,
    "com.tgc.sky.android": comTgcSkyAndroidConfig,
    "com.h73.jhqyna": comH73JhqynaConfig,
    "com.garena.game.df": comGarenaGameDfConfig,
    "com.supercell.brawlstars": comSupercellBrawlstarsConfig,
    "com.levelinfinite.sgameGlobal": comLevelinfiniteSgameGlobalConfig,
    "com.garena.game.kgth": comGarenaGameKgthConfig,
    "com.garena.game.kgvn": comGarenaGameKgvnConfig,
    "com.sega.persona5.the.phantomx.en": comSegaPersona5ThePhantomxEnConfig,
    "com.blackstorm.rememento.android.google": comBlackstormRemementoAndroidGoogleConfig,
    "com.xd.etheria.gp.glb": comXdEtheriaGpGlbConfig,
    "com.tencent.ig": comTencentIgConfig,
    "com.kakaogames.gdts": comKakaogamesGdtsConfig,
    "com.mobile.legends": comMobileLegendsConfig,
    "com.dts.freefire": comDtsFreefireConfig,
    "com.kitkagames.fallbuddies": comKitkagamesFallbuddiesConfig,
    "com.innersloth.spacemafia": comInnerslothSpacemafiaConfig,
    "com.supercell.squad": comSupercellSquadConfig,
    "com.YoStarEN.AzurLane": comYoStarENAzurLaneConfig,
    "YZGAME": YZGAMEConfig  // Custom game support
};

CloudMoon Infrastructure

πŸ” Official API Endpoints (from code)

REST API:

https://api.prod.cloudmoonapp.com/
β”œβ”€β”€ /web/game_config?pkg={package}     # Game configuration
β”œβ”€β”€ /web/ad                             # Ad placement check
└── /phone/action                       # Quality/instance control
    POST {
        android_id: userId,
        game_name: game,
        instance_id: androidInstanceId,
        key: 'quality',
        value: 'HD|SD|LD|ULD'
    }

Shop/Premium:

https://shop.cloudmoonapp.com/shop

WebSocket Signaling:

wss://{coordinator_host}/client/socket.io
  • coordinator_host is dynamic, extracted from androidInstanceId (base64)
  • Fallback: current page hostname

STUN Servers:

stun:stun.nenly.cn:53006              # Their custom STUN
stun:stun.l.google.com:19302          # Google STUN (fallback)

πŸ” CDN & External Services

Library CDN:

https://s3.ap-southeast-1.amazonaws.com/cdn.cloudbatata.com/ajax/libs/
β”œβ”€β”€ vue/2.6.12/vue.min.js
β”œβ”€β”€ socket.io/2.4.0/socket.io.js
β”œβ”€β”€ axios/0.26.0/axios.min.js
└── vConsole/3.3.4/vconsole.min.js

Other CDNs:

https://cdn.jsdelivr.net/npm/
└── sweetalert2@11.26.2/

Analytics:

https://www.googletagmanager.com/gtag/js?id=G-Q30NXCCZNN

Ad Network:

https://hb.vntsm.com/v4/live/vms/sites/cloudmoonapp.com/index.js

πŸ’‘ Inferred Server Architecture

Based on code evidence:

  1. Kubernetes-based (from podName, podLocale DataChannel messages)
  2. Containerized Android instances (each has androidIp, isolated)
  3. Regional deployment (podLocale suggests multiple datacenters)
  4. Dynamic coordinator routing (coorUrl in androidInstance)

Ping display shows (index.html line 5):

/ {{userId}} / {{podLocale}} / {{androidInstance?.androidIp}} / {{utcDate}}

This suggests:

  • Each user gets a dedicated Android instance
  • Instance has its own IP address on internal network
  • Pod locale indicates datacenter region
  • Instances are ephemeral (allocated per session)

πŸ” Quality Settings

From app-methods.js:

// Quality options
"HD"  - High Definition
"SD"  - Standard Definition
"LD"  - Low Definition
"ULD" - Ultra Low Definition

// Sent to server via:
POST https://api.prod.cloudmoonapp.com/phone/action
{
    key: 'quality',
    value: 'HD'  // etc
}

// Server adjusts encoder bitrate accordingly

πŸ” Analytics Events

From code (gtag events):

gtag('event', 'socket_connecting');
gtag('event', 'socket_connected');
gtag('event', 'receive_offer');
gtag('event', 'receive_stream');
gtag('event', 'ice_connection_state_${state}');
gtag('event', 'user_show_ad');
gtag('event', 'user_block_ad');
gtag('event', 'play_for_${minutes}_min');  // 1-30 minutes

Key Insights

βœ… What We Know for Certain

  1. Client is Vue.js 2.6 with Socket.IO for signaling
  2. WebRTC uses H.264/VP8 codecs
  3. All input goes through DataChannel (not WebSocket)
  4. Adaptive jitter buffer (40-200ms based on network)
  5. WASD joystick updates at 60fps via requestAnimationFrame
  6. 28+ games supported with per-game configs
  7. 3 keyboard layouts per game (user customizable)
  8. Pointer lock mode for FPS games
  9. Real-time latency display (RTT from ICE candidate pairs)
  10. Custom STUN server at stun.nenly.cn:53006

πŸ’‘ Educated Guesses

  1. ARM-based Android containers (likely Redroid or similar)
    • Evidence: androidIp field, podName Kubernetes terminology
  2. Global edge deployment
    • Evidence: podLocale, dynamic coorUrl
  3. GPU-accelerated encoding
    • Evidence: H.264 hardware encoding, low latency
  4. Session-based billing
    • Evidence: Time tracking, premium upgrade prompts

❓ Unknown (Server-Side Implementation)

  • Exact Android virtualization technology
  • Video encoding pipeline details
  • Input injection mechanism
  • Scaling/orchestration specifics
  • Billing/subscription system

Complete Server & Endpoint Reference

πŸ” All URLs Found in Code

CloudMoon Services:

https://api.prod.cloudmoonapp.com/web/game_config
https://api.prod.cloudmoonapp.com/web/ad
https://api.prod.cloudmoonapp.com/phone/action
https://shop.cloudmoonapp.com/shop
https://web.cloudmoonapp.com/
wss://{dynamic}/client/socket.io
stun:stun.nenly.cn:53006

CDN & Libraries:

https://s3.ap-southeast-1.amazonaws.com/cdn.cloudbatata.com/ajax/libs/
  - vue/2.6.12/vue.min.js
  - socket.io/2.4.0/socket.io.js
  - axios/0.26.0/axios.min.js
  - vConsole/3.3.4/vconsole.min.js

https://cdn.jsdelivr.net/npm/
  - sweetalert2@11.26.2/dist/sweetalert2.all.min.js
  - sweetalert2@11.26.2/dist/sweetalert2.min.css
  - sweetalert2@11.26.2/themes/material-ui.css

Third-Party Services:

https://www.googletagmanager.com/gtag/js?id=G-Q30NXCCZNN  (Analytics)
https://hb.vntsm.com/v4/live/vms/sites/cloudmoonapp.com/   (Ad network)
stun:stun.l.google.com:19302                               (Google STUN)

Document Version: 2.0 (Code Analysis Only) Based on: CloudMoon Web Client (build_time=1764914045706) Analysis Date: 2025-12-13 Files Analyzed: 35 JavaScript files, 1 HTML file, 1 CSS file

Keep reading