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.