Build real-time browser experiences
RCWeb App Development Guide
Build small browser apps that collaborate across phones, displays, laptops, kiosks, and other devices through one durable real-time communications layer.
The app owns the experience. RCWeb owns the connection.
An RCWeb App is a standalone HTML, CSS, and vanilla JavaScript project. Each browser joins a virtual room; the RCWeb server brokers targeted JavaScript calls between clients in that room. Application state and rendering stay in browser pages, so new app behavior does not require a new REST API, database schema, server build, or frontend toolchain.
For app-only changes, save the file and refresh the browser. The same core supports a phone controlling a shared screen, equal peers synchronising state, multiplayer games, collaborative tools, and mixed-role experiences.
HTML + CSS + JavaScript Virtual rooms WebSockets Targeted callbacks No app build step
1. The RCWeb Mental Model
RCWeb is a room-scoped message broker for browser pages. It serves each app, gives the browser a room ID and client ID, maintains a WebSocket, and forwards commands to matching clients. The Java server does not execute app JavaScript or interpret app-specific state.
1Load
A browser opens /<app-name>/ and loads the app plus
/assets/core/comms.js.
2Join
rc.connect() loads app-specific setup data and joins the room selected by the URL.
3Send
The app targets a remote function call by app name, client ID, wildcard, or allow/deny list.
4Run
Matching browsers execute the call, validate its arguments, update local state, and render.
Rooms, Apps, and Clients
| Concept | Example | Purpose |
|---|---|---|
| Room | test-0001 |
Scopes a live collaboration session. Clients exchange commands only inside their room. |
| App | quiz, quiz-c |
Names the browser experience and provides a convenient message target. |
| Client | 12345678 |
Identifies one connected browser so replies and private state can be targeted precisely. |
Keep the server generic. Define app behavior as a small remote API
in browser code. A viewer can expose quiz.submitAnswer or game.jump without teaching
the Java server what an answer or jump means.
2. Create an App
Required Project Structure
Give every app its own directory under src/main/apps/app/:
src/main/apps/app/my-app/
index.html Entry page
style.css App-specific presentation
script.js State, events, remote API, and connection setup
appinfo.md Markdown shown at /my-app/appinfo
Large resources that should not change during normal app editing may live in
src/main/immutable/app/my-app/. Keep ordinary source and frequently edited assets with the app.
Do not add remote libraries, frameworks, fonts, package managers, or build systems without explicit approval.
A Minimal index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My RCWeb App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="A real-time RCWeb application.">
<link rel="stylesheet" href="style.css">
<noscript>
<meta http-equiv="refresh" content="0; url=/assets/noscript.html">
</noscript>
</head>
<body>
<main id="app"></main>
<script src="/assets/core/comms.js"></script>
<script src="script.js"></script>
</body>
</html>
comms.js must load before the app script. Use a classic script for remote APIs; module-scoped
functions are not visible to incoming calls unless they are explicitly attached to window.
A Practical script.js Starting Point
rc.onUpdateNetworkStatus = function (heading, info) {
myApp.showStatus(heading + " - " + info);
};
rc.onUpdateError = function (error) {
myApp.showStatus("Update failed: " + error);
};
rc.onConnected = function () {
myApp.onConnected();
};
var myApp = (function () {
var state = { message: "Ready" };
var statusElement;
var render = function () {
document.getElementById("message").textContent = state.message;
};
var receiveUpdate = function (senderClient, update) {
if (senderClient === rc.client) {
return;
}
if (!update || typeof update.message !== "string") {
console.error("Invalid update received");
return;
}
state.message = update.message.substring(0, 200);
render();
};
var setMessage = function (message) {
state.message = message;
render();
rc.sendFunctionCall("my-app", "myApp.receiveUpdate", rc.client, {
message: state.message
});
};
var onConnected = function () {
showStatus("Connected to room " + rc.room);
document.getElementById("sendButton").disabled = false;
};
var showStatus = function (text) {
if (statusElement) {
statusElement.textContent = text;
}
};
var init = function () {
statusElement = document.getElementById("status");
render();
rc.connect();
};
return {
init: init,
onConnected: onConnected,
receiveUpdate: receiveUpdate,
setMessage: setMessage,
showStatus: showStatus
};
})();
window.addEventListener("load", myApp.init);
The object returned into the top-level var myApp is globally reachable in a classic script, so
peers can call myApp.receiveUpdate. Only expose methods that are meant to be remote entry points.
3. Connection Lifecycle
Setup is asynchronous. Calling rc.connect() does not immediately make room and client values
available.
1Before Connect
Define hook overrides, the global remote API, local state, rendering, and event handlers. Do not read
rc.room, rc.client, or rc.commsWebSocket yet.
2Setup
rc.connect() loads the app's setup.js. Setup supplies the server version,
app name, validated room ID, generated client ID, and WebSocket URL.
3Socket Open
comms.js opens the WebSocket, marks rc.connected true, increments
rc.connectionCount, calls rc.onConnected(), and flushes queued sends.
4Reconnect
Closed connections retry automatically. rc.onConnected() runs after every successful
reconnect, so use it to restore room-dependent UI and request current state again.
Do not attach duplicate listeners during reconnect. Bind DOM events
once in init(). Keep onConnected() idempotent and reserve it for connection-aware
state, presence announcements, and refresh requests.
Core Runtime Reference
| API or Hook | Use | Important Detail |
|---|---|---|
rc.connect() |
Load setup and connect, or reconnect an already configured client. | Define overrides before calling it. |
rc.disconnect() |
Close the current socket and cancel pending reconnect timers. | Use for an intentional shutdown or controlled restart. |
rc.send(js, target) |
Send deliberately constructed JavaScript. | Returns sent, queued, or queued_replaced_oldest. |
rc.sendFunctionCall(...) |
Serialize arguments and invoke a named global function remotely. | Preferred for ordinary app messages. |
rc.onConnected() |
Start work that needs room, client, app, or socket values. | Runs after initial connection and reconnects. |
rc.onUpdateNetworkStatus() |
Show connection and queue status in the UI. | Useful for visible offline feedback. |
rc.onUpdateClients(clients) |
React when the room's connected client list changes. | The list contains client IDs, not app-owned state. |
rc.onUpdateSuccess(js) |
Observe successful execution on the receiving browser. | It is not automatically an acknowledgement to the sender. |
rc.onUpdateError(error) |
Surface a remote call that threw on the receiving browser. | Log visibly; send a deliberate acknowledgement if the sender needs one. |
rc.onNoUpdate() |
Observe an idle keepalive. | Usually left at its default. |
rc.sendFileChunk(...) |
Supply requested data to the browser-hosted file proxy. | Override only in apps that host files. |
Offline Sends and the Queue
If the WebSocket is temporarily unavailable, rc.send queues messages in first-in, first-out order.
The core keeps at most 200 queued sends; once full, it drops the oldest entry and reports the condition through
rc.onUpdateNetworkStatus. Do not treat the queue as durable storage. After reconnect, request or send
an authoritative snapshot so dropped or stale transient events cannot leave the experience inconsistent.
4. Messaging and Targeting
Prefer Remote Function Calls
rc.sendFunctionCall(
"scoreboard",
"scoreboard.addPoint",
rc.client,
"blue"
);
rc.sendFunctionCall serializes each argument with JSON.stringify and constructs a call to
the named global function. Keep the function name static and validate arguments in the receiver. Objects, arrays,
strings, numbers, booleans, and null values work naturally; functions and cyclic objects do not.
Use Raw JavaScript Deliberately
rc.send(
"document.location.href='/v/?r=" + encodeURIComponent(rc.room) + "';",
"v"
);
Raw rc.send is appropriate when the command is intentionally browser code, such as redirecting a
viewer or installing generated trusted markup. Never concatenate untrusted user text into a JavaScript string.
Switch to sendFunctionCall whenever the task can be represented as data passed to a known function.
Target Selectors
The target is evaluated only among clients in the sender's room. Whitespace is ignored in the selector list.
| Selector | Recipients | Typical Use |
|---|---|---|
chat |
Every client whose app name is exactly chat. |
Broadcast to peers running the same app. |
12345678 |
One client with that ID. | Private response or state snapshot. |
* |
Every client in the room. | Room-wide command; use sparingly. |
spacewar* |
Apps whose names start with spacewar. |
Address a related app family. |
*-c |
Apps whose names end in -c. |
Address controller apps. |
chat,notepad |
Clients matching either app name. | Explicit allow list. |
!chat |
Every room client except the chat app. |
Deny-only selection. |
chat,!12345678 |
All chat clients except one client. |
Broadcast without echoing to a particular peer. |
!c,*-c |
Every client except the default and suffixed controller apps. | Target display-side experiences. |
Send intents, not implementation. Prefer
game.jump(playerId) or poll.submitVote(optionId) over sending DOM mutations. The
receiver can validate the intent, own its state, and render consistently across versions.
5. Choose a Collaboration Pattern
Asymmetric: Viewer + Controllers
A landscape display owns the shared world. One or more phones send user intents and receive private acknowledgements or state. This is the default for games, presentations, dashboards, and signage.
- Keep authoritative state in the viewer.
- Include
rc.clientwith controller actions. - Target the viewer app, not every room client.
- Send snapshots back to controller app names or individual client IDs.
Symmetric: Equal Peers
Every browser runs the same app and can change shared state. This fits chat, notes, drawing, voting, shared instruments, and lightweight collaborative tools.
- Include sender and event IDs.
- Ignore echoes and deduplicate repeated events.
- Define a deterministic conflict rule.
- Request current state on join and reconnect.
Authoritative Viewer Example
var scoreboard = (function () {
var scores = { blue: 0, red: 0 };
var addPoint = function (senderClient, team) {
if (typeof senderClient !== "string") {
return;
}
if (team !== "blue" && team !== "red") {
return;
}
scores[team] += 1;
render();
sendSnapshot(senderClient);
};
var requestState = function (requesterClient) {
if (typeof requesterClient === "string") {
sendSnapshot(requesterClient);
}
};
var sendSnapshot = function (target) {
rc.sendFunctionCall(target, "scoreboardControl.receiveState", scores);
};
var render = function () {
document.getElementById("blueScore").textContent = scores.blue;
document.getElementById("redScore").textContent = scores.red;
};
return {
addPoint: addPoint,
requestState: requestState
};
})();
rc.connect();
The controller sends scoreboard.addPoint(rc.client, "blue"). The viewer checks the input, changes
authoritative state, renders, and acknowledges with a snapshot targeted to the sending client. Controllers do
not calculate scores independently.
6. State, Joining, and Reconnecting
The server routes commands but does not retain application history. Each app must decide where truth lives and how a newly opened or reconnected client catches up.
| App Shape | Source of Truth | Join / Reconnect Strategy |
|---|---|---|
| Viewer + controllers | The viewer | Controller sends requestState(rc.client); viewer replies directly. |
| Symmetric document | Peer snapshot plus revision rule | New peer requests state; existing peers debounce replies; receiver accepts the selected newest snapshot. |
| Event stream | Validated event history in peers | Share a bounded history and deduplicate by event ID. |
| Ephemeral control | Current viewer state | Ignore old motion events; send a fresh control state after reconnect. |
A Reliable Refresh Flow
- On every
rc.onConnected(), request current state from the authority or peers. - Include the requester's client ID so the response can be private.
- Debounce peer responses to avoid a refresh storm when several clients join together.
- Include a revision, timestamp, or event ID and define exactly which snapshot wins.
- Render from the accepted state; do not replay stale queued pointer or control events over it.
Valid Room IDs
Use short test rooms such as test-0001 or mw01-t001. Valid room IDs contain 4 to 11
ASCII letters, digits, and hyphens; generated rooms normally look like abcd-efgh. If an invalid
?r= value is supplied, setup silently generates a replacement. Two pages opened with the same
invalid value can therefore end up in different generated rooms and appear unable to communicate.
Debounce Noisy Inputs
var sendTimer;
var queueTextUpdate = function () {
window.clearTimeout(sendTimer);
sendTimer = window.setTimeout(function () {
rc.sendFunctionCall("notes", "notes.receiveText", rc.client,
document.getElementById("editor").value);
}, 120);
};
Typing, sliders, drawing, pointer movement, orientation sensors, joysticks, and animation state should be debounced, throttled, sampled, or reduced to meaningful intents. Network frequency is part of the app design.
7. Share Large Files Through a Browser Host
Do not put large binary data inside JavaScript messages. Keep the File or Blob in the
source browser and publish an active proxy URL:
var safeName = encodeURIComponent(file.name);
var fileUrl = "/x-file/" + rc.room + "/" + rc.client + "/" + fileId + "/" + safeName;
rc.sendFunctionCall("gallery", "gallery.receiveFile",
rc.client, file.name, file.type, fileUrl);
When another browser requests the URL, RCWeb calls rc.sendFileChunk(fileId, start, url) in the
hosting page. The host slices the requested range and uploads it to the supplied URL:
rc.sendFileChunk = function (fileId, start, url) {
var file = sharedFiles[fileId];
var chunkSize = 1000000;
var end;
var xhr;
if (!file) {
console.error("Missing shared file " + fileId);
return;
}
end = Math.min(file.size, start + chunkSize);
xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
xhr.setRequestHeader("Content-Range",
"bytes " + start + "-" + (end - 1) + "/" + file.size);
xhr.send(file.slice(start, end));
};
The hosting browser must remain open. RCWeb streams the bytes from that page; it does not permanently upload the complete file. Warn users before navigation, handle missing file IDs visibly, encode file names, and validate file type and size before rendering.
8. Design for the Devices in the Room
Viewer and Shared Display
Design primarily for landscape at 1920×1080 and 960×540, then make the composition work in portrait at 1080×1920 and 450×960. Use readable type, strong contrast, simple regions, and scaling that survives a distant audience.
Controller and Phone
Optimise for portrait touch use, large tap targets, concise labels, clear connection feedback, and
one-handed operation where practical. Disable actions until the first connection if they require
rc.client or room state.
Compatibility Contract
- Write ES5-style JavaScript:
var, functions, object literals, and string concatenation. - Avoid arrows, classes, modules, template literals, destructuring, spread, promises, and
async/awaitunless explicitly approved. - Use conservative CSS available around 2015: media queries, positioning, transforms, transitions, and simple flexbox where suitable.
- Avoid CSS Grid, custom properties, container queries,
:has(), nesting, and other modern-only features unless the target devices are explicitly changed. - Use feature detection for optional browser APIs such as sensors, camera, fullscreen, and media playback.
Lean Paint for Older Displays
Some signage browsers have slow CPU painting and weak or absent GPU compositing. Gradients,
backdrop-filter, filter, and large box-shadow effects can cause multi-second
repaints. Include a lean-paint path that switches to flat colours and removes expensive effects. The
stack-snap app is the reference pattern.
9. Security and Trust Boundaries
RCWeb deliberately executes received JavaScript in matching browser pages. That makes the protocol expressive, but it also means a room is a trusted collaboration context—not a general-purpose hostile-code sandbox.
RCWeb Contains
- App code executes in browser pages, not the Java process.
- Room routing separates unrelated live sessions.
- App and client targets reduce the recipient set.
- Each page has its own DOM, globals, and local state.
- Browser permission and origin rules still apply.
The App Must Enforce
- Who may join when identity or access control matters.
- Argument type, range, length, and state validation.
- Role and turn rules for every remote intent.
- Safe rendering and URL/file validation.
- Rate limits and abuse handling where required.
Remote Boundary Checklist
- Expose the smallest possible global API; keep internal helpers private inside an object or closure.
- Validate every remotely callable method as though its arguments came from another device—because they did.
- Use
textContentfor user text. ReserveinnerHTMLfor trusted, controlled markup. - Keep API keys, model credentials, passwords, and private tokens out of browser source.
- Prefer
sendFunctionCall; never concatenate untrusted values into raw JavaScript. - Treat room IDs as routing identifiers, not authentication secrets.
- Make rejected actions visible in the controller UI or logs; do not silently pretend they succeeded.
10. Test the Shared Experience
Fast Local Loop
- Open the app at
http://localhost:8080/my-app/?r=test-0001. - Open its peer or controller with the same valid room, for example
http://localhost:8080/my-app-c/?r=test-0001. - Refresh after source edits. App-only HTML, CSS, and JavaScript changes do not require Maven or a server restart.
- Inspect both browser consoles. The receiving page reports remote execution errors.
- Watch the visible connection status and confirm the actual room ID on both pages.
Behavioral Test Matrix
| Area | What to Prove | Common Failure |
|---|---|---|
| Connection | Both pages show the same room and recover after a disconnect. | Using room/client values before onConnected. |
| Targeting | Only the intended app or client receives each command. | Broadcasting to * or echoing to controls accidentally. |
| Late join | A new page reconstructs current state without manual action. | Only sending transient changes with no snapshot flow. |
| Validation | Malformed, duplicate, stale, and out-of-turn calls are rejected visibly. | Trusting the controller to enforce viewer-owned rules. |
| Performance | High-frequency input stays responsive and network traffic remains bounded. | Sending every pointer or sensor event. |
| Responsive UI | Phone, landscape viewer, and portrait viewer sizes remain readable without overflow. | Testing only one desktop viewport. |
| Weak display | Lean-paint mode avoids expensive repaints. | Large shadows, filters, and animated gradients. |
Definition of Done
- The page loads
comms.jsbefore the app script and connects only after hooks are defined. - Room-dependent startup lives in
rc.onConnected()and remains safe on reconnect. - Remote callbacks are global, minimal, validated, and tested with invalid inputs.
- Target selectors reach exactly the intended roles and avoid unwanted echoes.
- The state authority and late-join refresh flow are explicit.
- Noisy updates are rate-limited and stale queued events cannot overwrite a current snapshot.
- Errors, rejected actions, missing files, and disconnects are visible to the user or developer.
- The controller is comfortable on a phone and the viewer works at all four target dimensions.
- Older displays have a lean-paint route when the design uses expensive visual effects.
appinfo.mdexplains the experience, roles, controls, and any important limitations.
Start with one room and two pages
Build the Smallest Complete Collaboration
Choose who owns state, expose a tiny remote API, connect two browsers, and make one useful action survive a reconnect. RCWeb takes care of the transport; the app can stay focused on the experience.