Skip to main content

Webcam video

CrowdyJS 15.5 adds proximity webcam video beside voice: a sender's encoded frames reach every actor within distance chunks as ClientVideoNotifications (native opcode 143 → 144). The platform never looks inside a frame; the SDK cuts each frame into UDP-sized fragments on the way out and puts them back together on the way in. Read the cost section before you ship it — every receiver pays for every copy of your video.

Permission​

sendVideoPacket needs use_video_chat on the sender's access tier and on the grid under the target chunk. The key is opt-in: a new app's default tier carries use_voice_chat but not use_video_chat. Grant it on a tier deliberately (appAccess.updateTier); the default world grid and a player's self-claimed chunk then follow the tier. A send without it is refused with UNAUTHORIZED (error code 7) on the genericError / errors lane.

From replication server v0.43.0, a viewer needs use_video_chat too, and each player's video settings, contacts and blocks decide who sees whom; a player under 18 has video off until a parent consents. game.safety.commSettings(appId) says what applies to the player now, and game.safety.setCommSettings lowers it (CrowdyJS 18.24.0). See Voice, video, contacts and blocks.

If the app has replay logging on, video is recorded with the player's other inputs unless the app leaves it out (replayLoggingRecordsVideo: false). While it records video, your game shows players a notice and an indicator (the safety log).

Sending: udp.sendVideoFrame​

Capture small, encode to JPEG (or WebP), hand the whole frame to the SDK. It fragments (≤ 16 fragments, ~17.8 KB ceiling; a frame above it is refused, never sent partially) and sends one sendVideoPacket per fragment.

const video = await navigator.mediaDevices.getUserMedia({
video: { width: 128, height: 96, frameRate: 10 },
});
const track = document.createElement('video');
track.srcObject = video;
await track.play();
const canvas = new OffscreenCanvas(128, 96);
const ctx = canvas.getContext('2d')!;

let frameId = 0;
setInterval(async () => {
ctx.drawImage(track, 0, 0, 128, 96);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.5 });
if (blob.size > 8 * 1024) return; // skip rather than flood; lower quality next time
await game.udp.sendVideoFrame({
appId,
chunk: session.self.chunk!, // where you stand
uuid: session.self.uuid,
frame: new Uint8Array(await blob.arrayBuffer()),
frameId: (frameId = (frameId + 1) & 0xffff), // per sender, +1 per frame, wraps
codec: VideoCodec.JPEG, // 0; VideoCodec.WEBP is 1
distance: 1, // your chunk and its neighbours — keep it here
});
}, 100);

sendVideoFrame resolves to the number of fragments sent. Like audio, your own fragments come back only when your actor is near the target chunk, so an echo is not an acknowledgment; a refusal arrives as a correlated error.

Receiving: handlers.video + VideoFrameAssembler​

Every notification is one fragment. Decode the base64, feed it to one VideoFrameAssembler, and draw the frames it completes:

import { VideoFrameAssembler, decodeBase64 } from '@crowdedkingdoms/crowdyjs';

const assembler = new VideoFrameAssembler(); // 500 ms without progress abandons a frame
const faces = new Map<string, ImageBitmap>();

game.udp.subscribe({
video: async (n) => {
if (n.uuid === session.self.uuid) return; // your own fan-out echo
const frame = assembler.ingest(n.uuid, decodeBase64(n.videoData));
if (!frame) return; // delivered once, when complete
const bitmap = await createImageBitmap(
new Blob([frame.bytes], { type: frame.codec === 1 ? 'image/webp' : 'image/jpeg' }),
);
faces.get(n.uuid)?.close();
faces.set(n.uuid, bitmap); // draw it on that actor
},
actorLeft: (n) => { // the player is gone: free it now
assembler.forget(n.uuid);
faces.get(n.uuid)?.close();
faces.delete(n.uuid);
},
}, appId);

Reassembly rules the assembler applies for you: one buffer per (sender, frameId); complete when all fragCount indices arrived; an incomplete frame is abandoned when a newer frameId from the same sender arrives or after 500 ms; fragments for a frameId not newer than the last completed or abandoned one are dropped as stragglers. No retransmit — the next frame is the recovery. fragmentFrame and parseVideoFragmentHeader are exported too if you want the pieces; the header itself is specified in Wire formats → Video payload.

Leaving​

Release a sender's texture, bitmap and assembler state on actorLeft (above) — the server announces a departure about five seconds after the last actor update, or at once when the session ends — and keep your staleness fallback (the stores' 12 s reaper) for a lost datagram. See World Stores → actors.

Cost​

Bytes are billed on egress and a video fragment is copied to every actor within distance. One sender at 10 fps × 4 KB is ~40 KB/s (≈ 350 kbit/s) to each receiver; ten cameras on in one chunk is ~400 KB/s into every player standing there, and the app's egress meter counts every copy. Defaults that keep it sane: distance 0–1, ≤ 10 fps, frames ≤ 8 KB (JPEG quality ≈ 0.5 at 128×96 is 2–5 KB), a visible toggle, and stop sending when nobody is in range. Blocks with Friends (src/video/WebcamChat.ts) and the the-construct starter (src/platform/media/WebcamService.ts) are the reference implementations.