mig's blog

Face detection in Jocly

 

Face detection in Jocly

 

Next step after allowing WebRTC video calls between live players, Jocly now integrates face detection as a transversal feature that can be used potentially in any of the Jocly games that have 3D support. So far, only the game Mana uses the feature and applies the recognized face to a head mesh in the 3D scene.

 

 

The feature is not perfect and it is not uncommon to see a shifted face appearing in the mesh, but more or less it does the job and it’s enough to get a smile from the users which is the targeted aim.

We have had internal discussions about whether a nose should be present on the face mesh. Not everyone has the same face shape, so the position of the nose may not match the actual people face, but after some tests, it appeared that the error was affordable.

The actual face detection algorithm uses unchanged code from Liu Liu https://github.com/liuliu/ccv/tree/unstable/js

The CCV library does not contain much code, but it requires data (from the face.js) that are rather big. We ensure this code is only loaded if required for the game and skin.

This algorithm is given a still image as input (under the form of a canvas element) and returns an array of 0 to n objects, each of these representing a detected face in the image. We only considered the first object, assuming it was the less likely to be a false positive.

A face object as returned by the algorithm holds the x y position and the width and height of the detected region. In practice, we only saw cases where width and height were equal, defining a square region where the eyes are the top corners and the mouth at the bottom center.

 

The algorithm is integrated into Jocly 3D abstraction mechanism so that it becomes very easy to stick the recognized face from the video stream as a texture onto a mesh in the 3D scene.

Analyzing images in real time is a CPU intensive operation. To minimize processor usage, the algorithm is only run on the local video stream. Results of the detection (position and size of the face in the image) are handled locally and transmitted to the peer. Both parties can then copy the detected region of the video to the textures being used for local and remote videos.

On the local side, before the image is analyzed for face recognition, it is first saved as a canvas element. If the face recognition happens to be successful, the canvas element is kept so that when no face can be recognized, the texture is updated with the still image from the last success. A similar mechanism is used for the remote stream: when the recognition information is received from the peer, the current image being received is stored as the “success image”. However, since the peer video stream is not synchronized with the off-line recognition data, there is a risk that the image being saved does not match an actual face. The method could be improved by each party sending the still image along with the successful recognition data, ideally using a WebRTC data channel instead of the signaling server as it is done now.

The detected face region returned by the algorithm is smaller than we want to display as a face. After running some tests, we decided to add 10% of the size on the left, right and top and 30% at the bottom.

As for all the meshes used in Jocly that are not built from Three.js primitives, the 3D face geometry has been created in Blender. It is important to tune the UV parameters of the mesh to ensure the face texture is applied correctly.

 

The Jocly source code section implementing the video object with face detection and video texture:



	var Gadget3DVideo = GadgetMesh.extend({
	    init: function (gadget, options) {
	        options = $.extend(true, {
	            scale: [1, 1, 1],
	            playerSide: 1,
	            makeMesh: function (videoTexture, ccvVideoTexture) {
	                var material = new THREE.MeshBasicMaterial({
	                    map: videoTexture,
	                    overdraw: true,
	                    // side:THREE.DoubleSide
	                });
	                var geometry = new THREE.PlaneGeometry(12, 9, 1, 1);
	                var mesh = new THREE.Mesh(geometry, material);

	                return mesh;
	            },
	            videoPlaying: function (on) {},
	            ccvLocked: function (on) {},
	            ccv: false,
	            ccvMargin: [.10, .10, .30, .10],
	            ccvWidth: 80,
	            ccvHeight: 60,
	            hideBeforeFirstLock: true,
	        }, options);
	        this._super.call(this, gadget, options);
	        this.videoConnected = false;
	        this.videoErrorCount = 0;
	        this.videoSkipError = false;
	        this.shouldBeVisible = false;
	        this.gotFirstLock = false;
	    },
	    objectReady: function (mesh) {
	        mesh.visible = false;
	        for (var i = 0; i < mesh.children.length; i++)
	            mesh.children[i].visible = false;
	        this.streamReady(Gadget3DVideo.isStreamReady(this.options.playerSide));
	        this._super.apply(this, arguments);
	    },
	    createObject: function () {
	        Gadget3DVideo.addAvatar(this, this.options.playerSide);
	        var ccvTexture = null;
	        if (this.ccvContextKey)
	            ccvTexture = Gadget3DVideo.getCCVVideoTexture(this.options.playerSide, this.ccvContextKey)
	        var mesh = this.options.makeMesh.call(this,
	            Gadget3DVideo.getVideoTexture(this.options.playerSide), ccvTexture);
	        if (mesh)
	            this.objectReady(mesh);
	    },
	    remove: function () {
	        Gadget3DVideo.removeAvatar(this, this.options.playerSide);
	        this._super.apply(this, arguments);
	    },
	    show: function () {
	        this.shouldBeVisible = true;
	        if (this.videoConnected && (this.options.ccv == false || this.gotFirstLock || !this.options.hideBeforeFirstLock))
	            this._super();
	    },
	    hide: function () {
	        this.shouldBeVisible = false;
	        this._super();
	    },
	    streamReady: function (on) {
	        this.videoConnected = on;
	        if (on)
	            this.show();
	        else
	            this.hide();
	    },
	    ccvLocked: function (locked) {
	        if (locked && this.shouldBeVisible) {
	            this.gotFirstLock = true;
	            this.show();
	        }
	        this.options.ccvLocked(locked);
	    },
	});

	Gadget3DVideo.streams = {}
	Gadget3DVideo.renderLoopHooked = false;
	Gadget3DVideo.ccvLibRequested = false;
	Gadget3DVideo.getStream = function (playerSide) {
	    if (!this.streams[playerSide]) {
	        var vStream = {
	            stream: null,
	            avatars: [],
	            video: null,
	            videoImage: null,
	            videoContext: null,
	            videoTexture: null,
	            streamReady: false,
	            ownVideoElement: false,
	            mustAttachVideo: false,
	            mustDetachVideo: false,
	            errorCount: 0,
	            loopCount: 0,
	            local: false,
	            ccvVideoImage: null,
	            ccvInProgress: false,
	            ccvLock: null,
	            ccvContexts: {},
	            ccvLastAnalyzed: null,
	            ccvLastSuccess: null,
	        }
	        var video = $("video[joclyhub-video='" + playerSide + "']");
	        if (video.length > 0) {
	            vStream.video = video;
	        } else {
	            vStream.ownVideoElement = true;
	            vStream.mustAttachVideo = true;
	            vStream.mustDetachVideo = true;
	            vStream.video = $("<video/>").attr("autoplay", "autoplay").width(
	                160).height(120).css({
	                visibility: "hidden",
	                float: "left",
	            }).appendTo("body");
	        }
	        vStream.videoImage = this.makeCanvas(160, 120);
	        vStream.videoImageContext = vStream.videoImage[0].getContext('2d');
	        vStream.videoTexture = new THREE.Texture(vStream.videoImage[0]);
	        vStream.videoTexture.minFilter = THREE.LinearFilter;
	        vStream.videoTexture.magFilter = THREE.LinearFilter;
	        this.streams[playerSide] = vStream;
	    }
	    return this.streams[playerSide];
	}
	Gadget3DVideo.addStream = function (playerSide, stream, local) {
	    var $this = this;
	    var vStream = this.getStream(playerSide);
	    vStream.stream = stream;
	    vStream.local = local;
	    if (vStream.mustAttachVideo) {
	        vStream.mustAttachVideo = false;
	        JoclyHub.webrtc.attachMediaStream(vStream.video, stream);
	    }
	    if (!this.renderLoopHooked) {
	        this.renderLoopHooked = true;
	        threeCtx.animateCallbacks["Gadget3DVideo"] = {
	            _this: $this,
	            callback: $this.animate,
	        }
	    }
	}
	Gadget3DVideo.removeStream = function (playerSide) {
	    var vStream = this.streams[playerSide];
	    if (vStream) {
	        if (vStream.streamReady)
	            for (var i = 0; i < vStream.avatars.length; i++)
	                vStream.avatars[i].streamReady(false);
	        if (vStream.mustDetachVideo)
	            JoclyHub.webrtc.detachMediaStream(vStream.video);
	        vStream.videoImage.remove();
	        if (vStream.ownVideoElement)
	            vStream.video.remove();
	        if (vStream.ccvLastSuccess)
	            vStream.ccvLastSuccess.videoImage.remove();
	        if (vStream.ccvLastAnalyzed)
	            vStream.ccvLastAnalyzed.remove();
	        delete this.streams[playerSide];
	        if (this.renderLoopHooked) {
	            var streamCount = 0;
	            for (var s in this.streams)
	                streamCount++;
	            if (streamCount == 0) {
	                delete threeCtx.animateCallbacks["Gadget3DVideo"];
	                this.renderLoopHooked = false;
	            }
	        }
	    }
	}
	Gadget3DVideo.addAvatar = function (avatar, playerSide) {
	    var vStream = this.getStream(playerSide);
	    vStream.avatars.push(avatar);
	    if (!avatar.ccvContextKey)
	        avatar.ccvContextKey = "" + avatar.options.ccvWidth + "," + avatar.options.ccvHeight + "," + JSON.stringify(avatar.options.ccvMargin);
	    if (!vStream.ccvContexts[avatar.ccvContextKey]) {
	        var ccvContext = {
	            width: avatar.options.ccvWidth,
	            height: avatar.options.ccvHeight,
	            margin: avatar.options.ccvMargin,
	        }
	        ccvContext.videoImage = this.makeCanvas(ccvContext.width, ccvContext.height);
	        ccvContext.videoImageContext = ccvContext.videoImage[0].getContext('2d');
	        ccvContext.videoImageContext.fillStyle = "rgb(0,255,0)";
	        ccvContext.videoImageContext.fillRect(0, 0, ccvContext.width, ccvContext.height);
	        ccvContext.videoTexture = new THREE.Texture(ccvContext.videoImage[0]);
	        ccvContext.videoTexture.minFilter = THREE.LinearFilter;
	        ccvContext.videoTexture.magFilter = THREE.LinearFilter;
	        ccvContext.videoTexture.needsUpdate = true;
	        vStream.ccvContexts[avatar.ccvContextKey] = ccvContext;
	        //debugger;
	    }
	    return vStream.streamReady;
	}
	Gadget3DVideo.getVideoTexture = function (playerSide) {
	    var vStream = this.streams[playerSide];
	    if (vStream)
	        return vStream.videoTexture;
	    else
	        return null;
	}
	Gadget3DVideo.getCCVVideoTexture = function (playerSide, contextKey) {
	    var vStream = this.streams[playerSide];
	    if (vStream) {
	        var ccvContext = vStream.ccvContexts[contextKey];
	        if (ccvContext)
	            return ccvContext.videoTexture;
	    }
	    return null;
	}
	Gadget3DVideo.isStreamReady = function (playerSide) {
	    return this.streams[playerSide] && this.streams[playerSide].streamReady;
	}
	Gadget3DVideo.isCCVLocked = function (playerSide) {
	    return this.streams[playerSide] && this.streams[playerSide].ccvLock;
	}
	Gadget3DVideo.removeAvatar = function (avatar, playerSide) {
	    var vStream = this.streams[playerSide];
	    if (vStream)
	        for (var i = 0; i < vStream.avatars.length; i++)
	            if (avatar == vStream.avatars[i]) {
	                vStream.avatars.splice(i, 1);
	                break;
	            }
	}
	Gadget3DVideo.animate = function () {
	    for (var side in this.streams) {
	        var vStream = this.streams[side];
	        try {
	            /*
				if(vStream.loopCount % 100 ==0)
					console.error("Gadget3DVideo.animate loop",vStream.loopCount,side,vStream.video[0].readyState === vStream.video[0].HAVE_ENOUGH_DATA,
							vStream.video[0].getAttribute("joclyhub-video"));
				*/
	            vStream.loopCount++;
	            if (vStream.video[0].getAttribute("webrtc-attached") === "1" && vStream.video[0].readyState === vStream.video[0].HAVE_ENOUGH_DATA) {
	                vStream.videoImageContext.drawImage(vStream.video[0], 0, 0,
	                    vStream.videoImage[0].width, vStream.videoImage[0].height);
	                /*
					if(vStream.ccvLock) {
						var ctx=vStream.videoImageContext;
					    ctx.strokeStyle = "Lime";
					    ctx.strokeRect(vStream.ccvLock.x,vStream.ccvLock.y,vStream.ccvLock.width,vStream.ccvLock.height);        
					}
					*/
	                vStream.videoTexture.needsUpdate = true;
	                if (!vStream.streamReady) {
	                    vStream.streamReady = true;
	                    for (var i = 0; i < vStream.avatars.length; i++)
	                        vStream.avatars[i].streamReady(true);
	                }
	                var ccvLocalRequested = false;
	                var ccvRequested = false;
	                for (var i = 0; i < vStream.avatars.length; i++)
	                    if (vStream.avatars[i].options.ccv) {
	                        ccvRequested = true;
	                        if (vStream.local) {
	                            ccvLocalRequested = true;
	                            break;
	                        }
	                    }
	                if (ccvLocalRequested) {
	                    if (typeof (ccv) == "undefined") { // ccv library not loaded
	                        if (!this.ccvLibRequested) {
	                            this.ccvLibRequested = true;
	                            $("<script/>").attr("src", JoclyHub.hubPath + "/lib/face.js").attr("type", "text/javascript")
	                                .appendTo($("head"));
	                            $("<script/>").attr("src", JoclyHub.hubPath + "/lib/ccv.js").attr("type", "text/javascript")
	                                .appendTo($("head"));
	                        }
	                    } else {
	                        if (!vStream.ccvInProgress)
	                            this.ccvPoll(vStream);
	                    }
	                }
	                if (ccvRequested)
	                    this.ccvAnimate(vStream);
	            }
	        } catch (e) {
	            if (vStream.errorCount % 1000000 == 0)
	                console.error("Gadget3DVideo.animate error", vStream.errorCount, side, e);
	            vStream.errorCount++;
	        }
	    }
	}
	Gadget3DVideo.ccvLocked = function (vStream, locking) {
	    for (var i = 0; i < vStream.avatars.length; i++)
	        vStream.avatars[i].ccvLocked(locking);
	}
	Gadget3DVideo.ccvPoll = function (vStream) {
	    vStream.ccvInProgress = true;
	    var width = vStream.videoImage[0].width;
	    var height = vStream.videoImage[0].height;
	    var now = Date.now();

	    function CCVResult(comp) {
	        //console.log("got ccv result",Date.now()-now,"ms",comp);
	        /*
			for(var i=0;i<comp.length;i++) {
				var face=comp[i];
				console.log("face",i,face.x,",",face.y,",",face.width,"x",face.height);
			}
			*/
	        if (comp.length == 0) {
	            if (vStream.ccvLock) {
	                vStream.ccvLock = null;
	                Gadget3DVideo.ccvLocked(vStream, false);
	                JoclyHub.webrtc.sendCCVMessage({
	                    locked: false,
	                });
	            }
	        } else {
	            var face = comp[0];
	            var lock = vStream.ccvLock;
	            vStream.ccvLock = {
	                x: face.x,
	                y: face.y,
	                width: face.width,
	                height: face.height,
	            }
	            if (vStream.ccvLastSuccess)
	                vStream.ccvLastSuccess.videoImage.remove();
	            vStream.ccvLastSuccess = $.extend({
	                videoImage: vStream.ccvLastAnalyzed,
	                copied: false,
	            }, vStream.ccvLock);
	            vStream.ccvLastAnalyzed = null;
	            vStream.ccvLastAnalyzedContext = null;

	            if (!lock)
	                Gadget3DVideo.ccvLocked(vStream, true);
	            JoclyHub.webrtc.sendCCVMessage({
	                locked: true,
	                x: face.x,
	                y: face.y,
	                width: face.width,
	                height: face.height,
	            });
	        }
	        ReschedulePoll();
	    }

	    function ReschedulePoll() {
	        setTimeout(function () {
	            vStream.ccvInProgress = false;
	        }, 200);
	    }
	    if (!vStream.ccvLastAnalyzed) {
	        vStream.ccvLastAnalyzed = this.makeCanvas(vStream.videoImage[0].width, vStream.videoImage[0].height);
	        vStream.ccvLastAnalyzedContext = vStream.ccvLastAnalyzed[0].getContext("2d");
	    }
	    vStream.ccvLastAnalyzedContext.drawImage(vStream.videoImage[0], 0, 0, vStream.videoImage[0].width, vStream.videoImage[0].height);

	    if (JoclyHub.webrtc.webrtcDetectedBrowser == "firefox")
	        ccv.detect_objects({
	            //"canvas" : ccv.grayscale(vStream.ccvLastAnalyzed[0]),
	            "canvas": ccv.grayscale(vStream.videoImage[0]),
	            "cascade": cascade,
	            "interval": 5,
	            "min_neighbors": 1,
	            "async": true,
	            "worker": 1
	        })(CCVResult);
	    else
	        CCVResult(ccv.detect_objects({
	            //"canvas" : ccv.grayscale(vStream.ccvLastAnalyzed[0]),
	            "canvas": ccv.grayscale(vStream.videoImage[0]),
	            "cascade": cascade,
	            "interval": 5,
	            "min_neighbors": 1,
	            "async": false,
	            "worker": 1
	        }));
	}
	Gadget3DVideo.makeCanvas = function (width, height) {
	    return $("<canvas/>").attr("width", width).attr("height", height).width(width).height(height)
	        .css({
	        visibility: "hidden",
	        float: "left",
	    }).appendTo("body");
	}
	Gadget3DVideo.ccvAnimate = function (vStream) {
	    function DrawImage(ccvContext, ccvLock, source) {
	        var width = ccvLock.width * (1 + ccvContext.margin[1] + ccvContext.margin[3]);
	        var height = ccvLock.height * (1 + ccvContext.margin[0] + ccvContext.margin[2]);
	        var x = ccvLock.x - ccvLock.width * ccvContext.margin[3];
	        var y = ccvLock.y - ccvLock.height * ccvContext.margin[0];
	        if (x < 0) {
	            width += x;
	            x = 0;
	        }
	        if (y < 0) {
	            height += y;
	            y = 0;
	        }
	        if (x + width > source.width)
	            width = source.width - x;
	        if (y + height > source.height)
	            height = source.height - y;
	        ccvContext.videoImageContext.drawImage(source,
	            x, y, width, height,
	            0, 0,
	            ccvContext.width, ccvContext.height);
	        ccvContext.videoTexture.needsUpdate = true;
	    }

	    for (var contextKey in vStream.ccvContexts) {
	        var ccvContext = vStream.ccvContexts[contextKey];
	        if (vStream.ccvLock)
	            DrawImage(ccvContext, vStream.ccvLock, vStream.videoImage[0]);
	        else if (vStream.ccvLastSuccess && !vStream.ccvLastSuccess.copied) {
	            vStream.ccvLastSuccess.copied = true;
	            DrawImage(ccvContext, vStream.ccvLastSuccess, vStream.ccvLastSuccess.videoImage[0]);
	        }
	    }
	}
	Gadget3DVideo.receiveRemoteLock = function (message) {
	    for (var side in this.streams) {
	        var vStream = this.streams[side];
	        if (vStream.local)
	            continue;
	        var lock = vStream.ccvLock;
	        if (message.locked) {
	            vStream.ccvLock = {
	                x: message.x,
	                y: message.y,
	                width: message.width,
	                height: message.height,
	            };
	            if (vStream.ccvLastSuccess)
	                vStream.ccvLastSuccess.videoImage.remove();
	            var videoImage = this.makeCanvas(vStream.videoImage[0].width, vStream.videoImage[0].height);
	            var videoImageContext = videoImage[0].getContext("2d");
	            videoImageContext.drawImage(vStream.videoImage[0], 0, 0, vStream.videoImage[0].width, vStream.videoImage[0].height);
	            vStream.ccvLastSuccess = $.extend({
	                videoImage: videoImage,
	                copied: false,
	            }, vStream.ccvLock);
	            if (!lock)
	                Gadget3DVideo.ccvLocked(vStream, true);
	        } else {
	            if (lock) {
	                vStream.ccvLock = null;
	                Gadget3DVideo.ccvLocked(vStream, false);
	            }
	        }
	    }
	}
	$(document).bind("joclyhub.webrtc", function (event, data) {
	    try {
	        if (data.webrtcType == "mediaOn")
	            Gadget3DVideo.addStream(data.side, data.stream, data.local);
	        if (data.webrtcType == "mediaOff")
	            Gadget3DVideo.removeStream(data.side);
	        if (data.webrtcType == "ccv")
	            Gadget3DVideo.receiveRemoteLock(data.message);
	    } catch (e) {
	        console.error("xd-view joclyhub.webrtc error", e);
	    }
	});

Jocly WebRTC technical stuff

This article extends the Jocly WebRTC announcement article with technical details.

We cannot share the whole Jocly code, but here are the parts we use to make WebRTC work for connected users playing a live game, in the hope it can help developers setting up similar services.

This code works fine (with the exception of the latest Firefox nightly currently not being able to copy the remote video stream to a 3D mesh), but very certainly contains bugs. We appreciate you feedback in the comments or through any other way you find suitable.

The Firefox/Chrome interoperable WebRTC code is adapted from the apprtc application from http://code.google.com/p/webrtc-samples/source/browse/trunk/apprtc/

In the case of Jocly, we already have the two players in relation as they already agreed to play a game against each other, so there is no formal call establishment. Instead, if we detect that both players have WebRTC capabilities, the program requests each side to start the local media. When both sides have authorized their camera, we automatically start the WebRTC call, choosing arbitrarily which one is the calling party, which doesn’t make any difference from the user point of view since the callee will answer automatically.

The main client code is here:

JoclyHub.webrtc = {
	RTCPeerConnection : null,
	getUserMedia : null,
	attachMediaStream : null,
	reattachMediaStream : null,
	webrtcDetectedBrowser : null,
	detachMediaStream : null,
	localStream : null,
	remoteStream : null,
}

if (navigator.mozGetUserMedia) {
	$.extend(JoclyHub.webrtc, {
		webrtcDetectedBrowser : "firefox",
		pc_config : {
			"iceServers" : [ {
				"url" : "stun:23.21.150.121"
			} ]
		},
		pc_constraint : {
			'mandatory' : {},
			'optional' : [ {
				'DtlsSrtpKeyAgreement' : 'true'
			} ]
		},
		offer_constraints : {
			'mandatory' : {
				'MozDontOfferDataChannel' : true
			},
			'optional' : []
		},
		RTCPeerConnection : mozRTCPeerConnection,
		RTCSessionDescription : mozRTCSessionDescription,
		RTCIceCandidate : mozRTCIceCandidate,
		getUserMedia : navigator.mozGetUserMedia.bind(navigator),
		attachMediaStream : function(element, stream) {
			if ($(element).attr("webrtc-attached") == "1")
				console.log("Element already attached");
			$(element).attr("webrtc-attached", "1");
			element.mozSrcObject = stream;
			element.play();
		},
		reattachMediaStream : function(to, from) {
			to.mozSrcObject = from.mozSrcObject;
			to.play();
		},
		detachMediaStream : function(element) {
			$(element).attr("webrtc-attached", "0");
			element.mozSrcObject = null;
			element.pause();
		},
	});
	MediaStream.prototype.getVideoTracks = function() {
		return [];
	};
	MediaStream.prototype.getAudioTracks = function() {
		return [];
	};
} else if (navigator.webkitGetUserMedia) {
	$.extend(JoclyHub.webrtc, {
		webrtcDetectedBrowser : "chrome",
		pc_config : {
			"iceServers" : [ {
				"url" : "stun:stun.l.google.com:19302"
			} ]
		},
		pc_constraints : {
			'mandatory' : {},
			'optional' : [ {
				'DtlsSrtpKeyAgreement' : 'true'
			} ]
		},
		offer_constraints : {
			'mandatory' : {},
			'optional' : []
		},
		RTCPeerConnection : webkitRTCPeerConnection,
		RTCSessionDescription : RTCSessionDescription,
		RTCIceCandidate : RTCIceCandidate,
		getUserMedia : navigator.webkitGetUserMedia.bind(navigator),
		attachMediaStream : function(element, stream) {
			element.src = webkitURL.createObjectURL(stream);
			element.play();
			$(element).attr("webrtc-attached", "1");
		},
		reattachMediaStream : function(to, from) {
			to.src = from.src;
		},
		detachMediaStream : function(element) {
			$(element).attr("webrtc-attached", "0");
			element.src = '';
			element.pause();
		},
	});
	if (!webkitMediaStream.prototype.getVideoTracks) {
		webkitMediaStream.prototype.getVideoTracks = function() {
			return this.videoTracks;
		};
		webkitMediaStream.prototype.getAudioTracks = function() {
			return this.audioTracks;
		};
	}
	if (!webkitRTCPeerConnection.prototype.getLocalStreams) {
		webkitRTCPeerConnection.prototype.getLocalStreams = function() {
			return this.localStreams;
		};
		webkitRTCPeerConnection.prototype.getRemoteStreams = function() {
			return this.remoteStreams;
		};
	}
} else {
	console.log("Browser does not appear to be WebRTC-capable");
}

$.extend(JoclyHub.webrtc,
{
	media_constraints : {
		'optional' : [],
		'mandatory' : {}
	},

	init : function(stream) {
		console.log("WebRTC.Init");
		var $this = this;
		this.localStream = stream;
		this.audioMute(stream,false);
		var pc_config = $.extend(true,
				JoclyHub.webrtc.pc_config, {
					optional : [ {
						'DataChannels' : true
					} ],
				});
		try {
			this.peerConn = new this.RTCPeerConnection(
					pc_config, JoclyHub.webrtc.pc_constraints);
		} catch (e) {
			console.log("Failed to create PeerConnection, exception: " + e.message);
		}
		this.peerConn.onicecandidate = function(event) {
			if (event.candidate) {
				//console.log("Sending ICE candidate...", Date.now(), event.candidate);
				JoclyHub.frameData.liveSend({
							type : "event",
							event : "E_WEBRTC",
							webrtc : {
								type : "signaling",
								message : {
									type : 'candidate',
									label : event.candidate.sdpMLineIndex,
									id : event.candidate.sdpMid,
									candidate : event.candidate.candidate
								},
							},
						});
			} else {
				//console.log("End of candidates.");
			}
		}
		this.peerConn.onaddstream = function(event) {
			console.log("addstream");
			$this.audioMute(event.stream,false);
			$this.remoteStream = event.stream;
			$(document).trigger("joclyhub.webrtc", {
				webrtcType : "mediaOn",
				stream : event.stream,
				side : $this.peerSide
			});
		}
		this.peerConn.onremovestream = function(event) {
			console.log("removestream");
			$(document).trigger("joclyhub.webrtc", {
				webrtcType : "mediaOff",
				side : $this.peerSide
			});
			$this.remoteStream = null;
		}
		this.peerConn.addStream(stream);
		console.log("inited");
	},
	startLocal : function() {
		var $this = this;
		var constraints = $.extend(true, {
			mandatory : {
				minWidth : 160,
				minHeight : 120,
			},
		}, this.media_constraints);
		this.getUserMedia({
			'audio' : true,
			'video' : constraints,
		}, function(stream) {
			$(document).trigger("joclyhub.webrtc", {
				webrtcType : "mediaOn",
				stream : stream,
				side : $this.selfSide
			})
			$(document).trigger("joclyhub.webrtc", {
				webrtcType : "localMediaOn",
				stream : stream
			})
		}, function(error) {
			console.log("UserMedia failure", error);
		});
	},
	preferOpus : function(sdp) {
		var sdpLines = sdp.split('\r\n');
		for ( var i = 0; i < sdpLines.length; i++) {
			if (sdpLines[i].search('m=audio') !== -1) {
				var mLineIndex = i;
				break;
			}
		}
		if (mLineIndex === null)
			return sdp;
		for ( var i = 0; i < sdpLines.length; i++) {
			if (sdpLines[i].search('opus/48000') !== -1) {
				var opusPayload = this.extractSdp(sdpLines[i],
						/:(\d+) opus\/48000/i);
				if (opusPayload)
					sdpLines[mLineIndex] = this
							.setDefaultCodec(
									sdpLines[mLineIndex],
									opusPayload);
				break;
			}
		}
		sdpLines = this.removeCN(sdpLines, mLineIndex);
		sdp = sdpLines.join('\r\n');
		return sdp;
	},
	extractSdp : function(sdpLine, pattern) {
		var result = sdpLine.match(pattern);
		return (result && result.length == 2) ? result[1]
				: null;
	},
	setDefaultCodec : function(mLine, payload) {
		var elements = mLine.split(' ');
		var newLine = new Array();
		var index = 0;
		for ( var i = 0; i < elements.length; i++) {
			if (index === 3) // Format of media starts from
								// the fourth.
				newLine[index++] = payload; // Put target
											// payload to the
											// first.
			if (elements[i] !== payload)
				newLine[index++] = elements[i];
		}
		return newLine.join(' ');
	},
	removeCN : function(sdpLines, mLineIndex) {
		var mLineElements = sdpLines[mLineIndex].split(' ');
		for ( var i = sdpLines.length - 1; i >= 0; i--) {
			var payload = this.extractSdp(sdpLines[i],
					/a=rtpmap:(\d+) CN\/\d+/i);
			if (payload) {
				var cnPos = mLineElements.indexOf(payload);
				if (cnPos !== -1) {
					mLineElements.splice(cnPos, 1);
				}
				sdpLines.splice(i, 1);
			}
		}
		sdpLines[mLineIndex] = mLineElements.join(' ');
		return sdpLines;
	},
	handleMessage : function(event, data) {
		try {
			// console.log("WebRTC: received
			// joclyhub.webrtc",data.webrtcType);
			switch (data.webrtcType) {
			case "peerDetails":
				console.log("peerDetails", data);
				JoclyHub.webrtc.peerPlayer = data.peer;
				JoclyHub.webrtc.selfSide = data.playingAs == 'a' ? 1
						: -1;
				JoclyHub.webrtc.peerSide = data.playingAs == 'a' ? -1
						: 1;
				JoclyHub.frameData.liveSend({
					type : "event",
					event : "E_WEBRTC",
					webrtc : {
						type : "mediaConfig",
						mediaConfig : "reciprocal",
					},
				});
				break;
			case "startLocalMedia":
				this.startLocal();
				break;
			case "mediaOn":
				console.log("webrtc.handleMessage: received mediaOn",data);
				break;
			case "localMediaOn":
				console.log("webrtc.handleMessage: received localMediaOn",data);
				JoclyHub.webrtc.init(data.stream);
				JoclyHub.frameData.liveSend({
					type : "event",
					event : "E_WEBRTC",
					webrtc : {
						type : "mediaReady",
					},
				});
				break;
			case "initiateCall":
				console.log("webrtc.handleMessage: received initiateCall",data);
				JoclyHub.webrtc.peerConn.createOffer(
					function(offer) {
						offer.sdp = JoclyHub.webrtc
								.preferOpus(offer.sdp);
						JoclyHub.webrtc.peerConn
								.setLocalDescription(offer);
						JoclyHub.frameData
								.liveSend({
									type : "event",
									event : "E_WEBRTC",
									webrtc : {
										type : "signaling",
										message : offer,
									},
								});
					},
					function(error) {
						console.log("Could not create SDP offer! Reason: "+ error);
					},
					$.extend(true,
						{
							'mandatory' : {
								'OfferToReceiveAudio' : true,
								'OfferToReceiveVideo' : true,
							},
						},
						JoclyHub.webrtc.offer_constraints)
				);
				break;
			case "expectCall":
				console.log("webrtc.handleMessage: received expectCall",data);
				break;
			case "signaling":
				var msg = data.message;
				//console.log("webrtc.handleMessage: received signaling",msg);
				if (msg.type == "offer") {
					JoclyHub.webrtc.peerConn.setRemoteDescription(new JoclyHub.webrtc.RTCSessionDescription(msg));
					JoclyHub.webrtc.peerConn.createAnswer(
						function(answer) {
							answer.sdp = JoclyHub.webrtc
									.preferOpus(answer.sdp);
							JoclyHub.webrtc.peerConn
									.setLocalDescription(answer);
							JoclyHub.frameData
									.liveSend({
										type : "event",
										event : "E_WEBRTC",
										webrtc : {
											type : "signaling",
											message : answer,
										},
									});
						},
						function(error) {
							console
									.log("Could not create SDP answer! Reason: "
											+ error);
						},
						{
							'mandatory' : {
								'OfferToReceiveAudio' : true,
								'OfferToReceiveVideo' : true,
							}
						}
					);
				} else if (msg.type == "answer") {
					JoclyHub.webrtc.peerConn.setRemoteDescription(new JoclyHub.webrtc.RTCSessionDescription(msg));
				} else if (msg.type == 'candidate') {
					var candidate = new JoclyHub.webrtc.RTCIceCandidate(
							{
								sdpMLineIndex : msg.label,
								candidate : msg.candidate,
							});
					//console.log("iceCandidate added to peerConnection",Date.now());
					JoclyHub.webrtc.peerConn.addIceCandidate(
						candidate,
						function(error) {
							if(error)
								console.log("addIceCandidate error",error);
						});
				}
				break;
			default:
				console.log("webrtc.handleMessage: ignored message",data);

			}
		} catch (e) {
			console.log("joclyhub.webrtc handleMessage Error",e);
		}
	},
	audioMute: function(stream,muted) {
		var audioTracks=stream.getAudioTracks();
		for(var i=0;i<audioTracks.length;i++)
			audioTracks[i].enabled = !muted;
	},
});

$(document).bind("joclyhub.webrtc", function(event, data) {
	JoclyHub.webrtc.handleMessage.call(JoclyHub.webrtc, event, data);
});

$(window).bind("unload",function() {
	JoclyHub.frameData
		.liveSend({
			type : "event",
			event : "E_WEBRTC",
			webrtc : {
				type : "signaling",
				message : { 
					type: "bye",
				},
			},
		});
	$(document).trigger("joclyhub.webrtc", {
		webrtcType : "localMediaOff",
		stream : null
	});
	$(document).trigger("joclyhub.webrtc", {
		webrtcType : "mediaOff",
		side : null,
		stream : null
	});
	if (JoclyHub.webrtc.localStream)
		JoclyHub.webrtc.localStream.stop();
	JoclyHub.webrtc.localStream = null;
	if (JoclyHub.webrtc.remoteStream)
		JoclyHub.webrtc.remoteStream.stop();
	JoclyHub.webrtc.remoteStream = null;
});


Notes for code above:
  • the whole Jocly client code uses jQuery, hence the use of $.extend as a convenience
  • JoclyHub.frameData.liveSend() is being used to send data to the node.js server through web sockets
  • incoming web sockets messages from the node.js server show up as joclyhub.webrtc events
  • this while code is only loaded and executed if we already checked the browser was WebRTC capable

The original apprtc code uses python running on Google appengine server. In Jocly, we already have a node.js server in place for the playing interactions, so we use this server to carry on the signaling between the two parties. The following code portion handles the WebRTC-specific messages.

JG.prototype.$handleWebRTC = function(args) {
	console.log("handleWebRTC", args.webrtc);

	function FakeCrypto(msg) {
		if (msg.sdp && !/a=crypto/.test(msg.sdp)) {
			msg.sdp = msg.sdp
					.replace(
							/c=IN/g,
							"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:BAADBAADBAADBAADBAADBAADBAADBAADBAADBAAD\r\nc=IN");
		}
	}

	switch (args.webrtc.type) {
	case "mediaConfig":
		if (args.player == 1)
			this.webrtc.player1.mediaConfig = args.webrtc.mediaConfig;
		else if (args.player == 2)
			this.webrtc.player2.mediaConfig = args.webrtc.mediaConfig;
		if (this.webrtc.player1.mediaConfig !== null
				&& this.webrtc.player2.mediaConfig !== null) {
			if (this.webrtc.player1.mediaConfig == "yes"
					|| (this.webrtc.player1.mediaConfig == "reciprocal" && (this.webrtc.player2.mediaConfig == "yes" || this.webrtc.player2.mediaConfig == "reciprocal")))
				this.player1.send({
					type : 'webrtc',
					webrtcType : 'startLocalMedia',
				});
			if (this.webrtc.player2.mediaConfig == "yes"
					|| (this.webrtc.player2.mediaConfig == "reciprocal" && (this.webrtc.player1.mediaConfig == "yes" || this.webrtc.player1.mediaConfig == "reciprocal")))
				this.player2.send({
					type : 'webrtc',
					webrtcType : 'startLocalMedia',
				});
		}
		break;
	case "mediaReady":
		if (args.player == 1)
			this.webrtc.player1.media = true;
		else if (args.player == 2)
			this.webrtc.player2.media = true;
		console.log("webrtc", this.webrtc);
		if (this.webrtc.player1.media == true
				&& this.webrtc.player2.media == true) {
			console.log("ready to connect");
			this.player1.send({
				type : 'webrtc',
				webrtcType : 'expectCall',
				opponent : this.player2.identity.uid,
			});
			this.player2.send({
				type : 'webrtc',
				webrtcType : 'initiateCall',
				opponent : this.player1.identity.uid,
			});
			console.log("initiateCall sent");
		}
		break;
	case "signaling":
		var msg = args.webrtc.message;
		FakeCrypto(msg);
		var destPlayer = null;
		if (args.player == 1)
			destPlayer = this.player2;
		else if (args.player == 2)
			destPlayer = this.player1;
		if (destPlayer)
			destPlayer.send({
				type : 'webrtc',
				webrtcType : 'signaling',
				message : msg,
			});
		else
			console.error("No destination player in message", args);
		break;
	}
}


In order to determine if a user has WebRTC capabilities in his browser, we use this function:

	function CanWebRTC() {
		if (navigator.mozGetUserMedia) {
			var m = /Firefox\/([0-9]+(\.[0-9]+)?)/.exec(navigator.userAgent);
			if (m) {
				var version = parseInt(m[1]);
				if (!isNaN(version) && version >= 22)
					return true;
			}
		} else if (navigator.webkitGetUserMedia) {
			var m = /Chrom(?:[^\/]*)\/([0-9]+)\.([0-9]+)\.([0-9]+)/
					.exec(navigator.userAgent);
			if (m) {
				var v1 = parseInt(m[1]);
				var v2 = parseInt(m[2]);
				var v3 = parseInt(m[3]);
				if (!isNaN(v1) && !isNaN(v2) && !isNaN(v3) && v1 >= 25
						&& (v2 > 0 || v3 >= 1364))
					return true;
			}
		}
		return false;
	}

When using a WebGL 3D theme to display the game scene, we integrate the video streams from the following code, adapted from https://github.com/stemkoski/stemkoski.github.com/blob/master/Three.js/Webcam-Texture.html

var Gadget3DVideo = GadgetMesh.extend({
	init : function(gadget, options) {
		options = $.extend(true, {
			scale : [ 1, 1, 1 ],
			playerSide : 1,
			makeMesh : function(videoTexture) {
				var material = new THREE.MeshBasicMaterial({
					map : videoTexture,
					overdraw : true,
				// side:THREE.DoubleSide
				});
				var geometry = new THREE.PlaneGeometry(12, 9, 1, 1);
				var mesh = new THREE.Mesh(geometry, material);

				return mesh;
			},
			videoPlaying : function(on) {
			},
		}, options);
		this._super.call(this, gadget, options);
		this.videoConnected = false;
		this.videoErrorCount = 0;
		this.videoSkipError = false;
		this.shouldBeVisible = false;
	},
	createObject : function() {
		var streamReady=Gadget3DVideo.addAvatar(this,this.options.playerSide);
		var mesh = this.options.makeMesh.call(this, Gadget3DVideo.getVideoTexture(this.options.playerSide));
		if (mesh) {
			mesh.visible = false;
			this.objectReady(mesh);
			this.streamReady(streamReady);
		}
	},
	remove : function() {
		//console.log("remove", this.gadget.id);
		Gadget3DVideo.removeAvatar(this,this.options.playerSide);
		this._super.apply(this, arguments);
	},
	show : function() {
		this.shouldBeVisible = true;
		if (this.object3d && this.videoConnected)
			this.object3d.visible = true;
	},
	hide : function() {
		this.shouldBeVisible = false;
		if (this.object3d)
			this.object3d.visible = false;
	},
	streamReady : function(on) {
		//console.log("streamReady",this.options.playerSide,this.gadget.id,on);
		this.videoConnected = on;
		if(this.shouldBeVisible && this.object3d)
			this.object3d.visible = on;
	},
	GetResource : GetResource,
});

Gadget3DVideo.streams={}
Gadget3DVideo.renderLoopHooked=false;
Gadget3DVideo.getStream=function(playerSide) {
	if(!this.streams[playerSide]) {
		var vStream={
			stream: null,
			avatars: [],
			video: null,
			videoImage: null,
			videoContext: null,
			videoTexture: null,
			streamReady: false,
			ownVideoElement: false,
			mustAttachVideo: false,
			mustDetachVideo: false,
			errorCount: 0,
			loopCount: 0,
		}
		var video = $("video[joclyhub-video='" + playerSide + "']");
		if (video.length > 0) {
			vStream.video = video;
		} else {
			//console.log("creating own video element");
			vStream.ownVideoElement = true;
			vStream.mustAttachVideo = true;
			vStream.mustDetachVideo = true;
			vStream.video = $("<video/>").attr("autoplay", "autoplay").width(
					160).height(120).css({
				visibility : "hidden",
				float : "left",
			}).appendTo("body");
		}
		vStream.videoImage = $("<canvas/>").attr("width", 160).attr("height",
				120).width(160).height(120)
				.css({
			visibility : "hidden",
			float : "left",
		}).appendTo("body");
		vStream.videoImageContext = vStream.videoImage[0].getContext('2d');
		vStream.videoTexture = new THREE.Texture(vStream.videoImage[0]);
		vStream.videoTexture.minFilter = THREE.LinearFilter;
		vStream.videoTexture.magFilter = THREE.LinearFilter;
		this.streams[playerSide]=vStream;
	}
	return this.streams[playerSide];
}
Gadget3DVideo.addStream=function(playerSide,stream) {
	var $this=this;
	var vStream=this.getStream(playerSide);
	vStream.stream = stream;
	if(vStream.mustAttachVideo) {
		vStream.mustAttachVideo=false;
		//console.warn("attaching stream");
		JoclyHub.webrtc.attachMediaStream(vStream.video,stream);
	}
	if(!this.renderLoopHooked) {
		//console.log("start rendering",playerSide);
		this.renderLoopHooked=true;
		threeCtx.animateCallbacks["Gadget3DVideo"] = {
			_this : $this,
			callback : $this.animate,
		}
	}
}
Gadget3DVideo.removeStream=function(playerSide) {
	var vStream=this.streams[playerSide];
	if(vStream) {
		if(vStream.streamReady)
			for(var i=0;i<vStream.avatars.length;i++)
				vStream.avatars[i].streamReady(false);
		if(vStream.mustDetachVideo)
			JoclyHub.webrtc.detachMediaStream(vStream.video);
		vStream.videoImage.remove();
		if(vStream.ownVideoElement)
			vStream.video.remove();
		delete this.streams[playerSide];
		if(this.renderLoopHooked) {
			var streamCount=0;
			for(var s in this.streams)
				streamCount++;
			if(streamCount==0) {
				delete threeCtx.animateCallbacks["Gadget3DVideo"];
				this.renderLoopHooked=false;
			}
		}
	}
}
Gadget3DVideo.addAvatar=function(avatar,playerSide) {
	var vStream=this.getStream(playerSide);
	vStream.avatars.push(avatar);
	//console.log("addAvatar",playerSide,vStream.streamReady)
	return vStream.streamReady;
}
Gadget3DVideo.getVideoTexture=function(playerSide) {
	return this.getStream(playerSide).videoTexture;
}
Gadget3DVideo.removeAvatar=function(avatar,playerSide) {
	var vStream=this.streams[playerSide];
	if(vStream)
		for(var i=0;i<vStream.avatars.length;i++)
			if(avatar==vStream.avatars[i]) {
				vStream.avatars.splice(i,1);
				break;
			}
}
Gadget3DVideo.animate=function() {
	for(var side in this.streams) {
		var vStream=this.streams[side];
		try {
			if(vStream.loopCount % 100 ==0)
				console.error("Gadget3DVideo.animate loop",vStream.loopCount,side,vStream.video[0].readyState === vStream.video[0].HAVE_ENOUGH_DATA,
						vStream.video[0].getAttribute("joclyhub-video"));
			vStream.loopCount++;
			if (vStream.video[0].getAttribute("webrtc-attached")==="1" && vStream.video[0].readyState === vStream.video[0].HAVE_ENOUGH_DATA) {
				vStream.videoImageContext.drawImage(vStream.video[0], 0, 0,
						vStream.videoImage[0].width, vStream.videoImage[0].height);
				vStream.videoTexture.needsUpdate = true;
				if(!vStream.streamReady) {
					//console.log("start copying",side);
					vStream.streamReady = true;
					for(var i=0;i<vStream.avatars.length;i++)
						vStream.avatars[i].streamReady(true);
				}
			}
		} catch(e) {
			if(vStream.errorCount % 20 ==0)
				console.error("Gadget3DVideo.animate error",vStream.errorCount,side,e);
			vStream.errorCount++;
		}
	}
}
$(document).bind("joclyhub.webrtc",
	function(event, data) {
		try {
			if (data.webrtcType == "mediaOn")
				Gadget3DVideo.addStream(data.side,data.stream);
			if (data.webrtcType == "mediaOff")
				Gadget3DVideo.removeStream(data.side);
		} catch(e) {
			console.error("xd-view joclyhub.webrtc error",e);
		}
	});

Note for the code above:

  • this code is integrated into the Jocly 2D/3D abstraction mechanism and won't work "as is"
  • the animate member function is being called from the main rendering loop
If you are interested in Jocly code, you can have a look at jjBlocks, the library we developed and use for dynamic layouts, and the Jocly dev API to develop or modify your own games.

Invitations are back

Invitations are back

 

When we moved to the hub playing interface, we did not have time to re-implement the invitation system that allowed a player to invite another one to play a game. This is now fixed !

1- Select a game

2- Click New turn-based game

3- Click Invite someone

4- Enter the user name of the player or an email address

5- Click Start

The invitation will appear in a new Invitations block and your opponent will receive a mail with a link to accept (or decline) your invitation.

It's now time to invite your mum to play Checkers !

A new interface for Jocly

Long time, no news. Not that we were slowing down our efforts on Jocly, it was actually the opposite: we were working hard on a new interface to make playing board games more enjoyable. The baby, we it call the Jocly Hub, is now ready to be shown !

If you are really in the hurry and don’t want to read what follows, the Jocly Hub is there.

First thing to know about the hub: it’s very new and as we write those lines, it has never been tested under heavy load. So if you notice the interaction with other players does not work as well as you expect, please accept our apologies. You may also notice bugs that went through our testing and we would appreciate a lot if you could report them (see below for bug reporting).

In our first implementation of Jocly, we organized the interface to fit into a regular web site: basically a web page per game being played. Playing several live games simultaneously or playing against the computer while waiting for a real human to join a game was really inconvenient.

So the idea for the Hub was to group everything into a single page where you can play as many games as you want (or as your system can support) while monitoring or creating duel offers, learning new rules, watching other playing, seeing demo, replaying older games, and chat with others, all of this at the same time.

We also wanted our interface to work on all platforms, from a powerful desktop with a 30” monitor to a regular 4” smartphone through standard iOS or Android tablets. Adapting the content to fit the device and doing this dynamically (you can rotate your tablet or phone) is called “responsive design”. Despite the fact this technology is currently a top trending one and very discussed about, we haven’t been able to find a framework that would fit our needs. So we have developed our own library, jBlocks, and released it as open source on GitHub.

 

We are very proud of the resulting application even if we think it can be largely improved. It may look a bit messy and unusual, as we expected from the beginning, but most of us have more fun visiting a bazaar than a cathedral don’t we ? But overall, it is quite usable.

We are now using exclusively the Persona (formerly BrowserId) authentication system to log into the hub. If you already had an account on Jocly, just create a Persona account (by clicking the login button) using the same email address you used for your former account.


 

Want to help ?

Be our guest. A few things you can do:

- if you are comfortable with bug tracking tools, you can report bugs you may have found or suggest improvements to our bugzilla server. If you report a bug, please be aware that describing the way to reproduce an issue is the very best way to get it debugged. Obviously bug reports just mentioning “sometimes it does not work” are not very helpful :)

- if you are a developer and want to join the jBlocks project, you are very welcome.

- give us feedback. We’d love to hear from you and your experience on the Jocly Hub. Talk to us on Facebook, Google+, Twitter or YouTube.

- talk about Jocly: write an article to your blog, tell your friends, …
 

Jocly Hub benefits over the former interface

- overall usability and common interface over various play modes: playing against the computer, live duels, turn-based games, viewing played game, ...

- finding an opponent whether in live or turn-based is easier

- turn-based games can be played in live if both players are connected at the same time

- turn-based games now support text messages between players

- games may be favorite’d by logged users for a quicker access

- global chat is being persisted

- optional sound notification on turn-based move, global chat, game acceptance
 

Known Jocly Hub issues

- some issues with sliding on mobile device: this is due to an incompatibility between 2 third-party javascript libraries we are using. We are working on this issue but for now, you may want to slide by moving your finger over the title bars of the hub boxes

- overall performance issue when manipulating the interface on a small CPU smartphone

- invitations are not yet (re)implemented

- player ELO ratings are not yet displayed

- older played games may not be accessible from the history list

We hope to see you soon on the Jocly Hub.

 

Turn based games

Up to now, to play games against someone, you had to use the duel mode for playing live. This is fun but it requires both players to be connected simultaneously and have some time available.

We just added the Turn-Based mode, so you can now play games where each turn can last hours, days or weeks. You get notified by mail when it is your turn to play, with a link that takes you directly to the game being played. Just keep in mind that after playing your move in that page, you must click the "Submit move" button for the system to acknowledge the move and notify your opponent that this is now his turn.

There are two ways to start a turn-based game:

  • Create an open game: any Jocly user can join this game. You can do so from this link
  • Invite someone: you can invite another player by either entering his/her Jocly username, or directly an email address. In this last case, the person will be requested to create an account in order to accept the invitation. You can invite some from this link

To check games waiting for an opponent to join, proceed to this link.

In order to keep track of the games you are playing and your invitations, you have the turn-based dashboard to monitor everything.

This feature is very new and we will appreciate your feedback on the issues you could see or improvements you would like.

 

Embedding game into forum content

A good way to talk about a game you played and you are particularly proud of (whether you won or lost) is to embed that game within the content you write, like i do here:



Legal (1702-1792) checkmate

With the new Jocly feature we just introduced, you can now do so by just including with the forum article you are writing this code:

{ @jocly.view-game:id=GAMEID}
where GAMEID is the ID of the game you want to display (see below how to get it).
Important: from the code example above, remove the space between { and @ (we added it to prevent the server to expand the placeholder)
 
To obtain the GAMEID:

  • either this was a game played in duel on Jocly: go to the archived games page, click  the desired game and pick the value from the URL. For instance, in http://www.jocly.com/jocly/view-game/7113a0ef436ed43d59e89ff2bb3760e1, the GAMEID is 7113a0ef436ed43d59e89ff2bb3760e1
  • or pick the game id from your saved games (go to your user menu to get them). Note that you can save a game to the server from the game lab using the new "Save to server" button, so it will appear in your saved games.

You can tune your game display using optional parameters:

  • w: the width (in pixels) of the game into your content
  • h: the height (in pixels) of the game into your content
  • m: the move index you want to start at

For instance, the code:

{ @jocly.view-game:w=200,h=240,id=5f5430d6e50d8aac65a557a031f326c5,m=7}
 
shows:

 
It is also possible to display a static position by using jocly.view-game-position instead of jocly.view-game like here

{ @jocly.view-game-position:w=200,h=240,id=5f5430d6e50d8aac65a557a031f326c5,m=7}

Fullscreen

 

 

You can now play your favorite games using the maximum space from your screen.

The feature is available in the player page, e.g. the Yohoho player, by clicking the Full Screen button at the bottom right. Fullscreen is also available from the game lab, e.g. the Hexplode Nuke lab, at the bottom.

To leave the fullscreen mode, just press the ESC key.

 

Webmaster API

Providing an attractive game as part of a web site retains visitors and increase the returns. This is what the Webmaster API brings: Jocly services directly into your web sites pages.

We just released the first beta. So far, only human vs computer mode is supported.

Integrating a Jocly game is as simple as inserting this HTML code:

<iframe src="http://www.jocly.com/jocly/wapi/{webmaster id}/play-game/{game name}"></iframe>

where

You can add width, height and frameborder attibutes to customize the iframe:

<iframe src="http://www.jocly.com/jocly/wapi/{webmaster id}/play-game/{game name}" width="300" height="400" frameborder="0"></iframe>

The game board will automatically adapt its viewport to fit your iframe dimension.

To get your webmaster id, just log into Jocly, go to http://www.jocly.com/user/, pick tab "Jocly Webmaster" and register as a webmaster. For the integration to work, you'll need to configure the domain names of the sites you want to embed Jocly to.

If you are comfortable with AJAX, it is also possible to go further and get data about games before launching them, so you can provide a full game portal to your users. More information from this link.

Let us know if this API matches your webmaster needs.

Refining Scrum rules

If you follow Jocly activities, you will have noticed that we recently released our game developer interface. This interface has been primarily designed for HTML5 developers (Javascript, CSS, ...) to implement their own games or improve existing games. However, even if you are not a programmer, there are many things you can do just by tweaking  a few parameters in the game declaration. The fact the rules can be changed from the configuration depends on the implementation, some games are more hardcoded while some are very configurable.

Scrum is a Jocly game with very simple rules which has a configurable implementation: the size of the board (width and height) as well as the number and the initial position of the player pieces and ball can be easily modified and tested without any knowledge of development.

When I had the idea of Scrum, some 30 years ago, the material i had was a Mastermind board. This is why the game is played on a 5 rows by 12 columns field. Also, the initial players and ball position hasn't changed since the very first days of the game. This was a rough approximation of the position for a 7 players rugby team.

Other board sizes and players/ball initial position might lead to a more interesting game in terms of players experience. If you enjoy games and want to help improving Scrum, you can do so without knowledge of development.

Go to http://www.jocly.com/jocly/dev and sign-up as a developer.

Create a new project using Scrum as the base template.

From the developer menu (the triangle at the top right), you can verify immediately that you can run the unmodified game by picking "Run in lab"

Go back to your developer project and click Model in the left panel.

You can adjust the board size by changing the values of width and height within gameOptions.

In the same way, you can modify the position of the player pieces in gameOptions/initial/a (player A) and gameOptions/initial/b (player B) and gameOptions/initial/ball (for the ball). Position are entered as [ row, column ] where row and column start from 0.

Just make sure the players and ball position fit within the board size otherwise the game won't work.

Of course, if you are an artist, you can also replace the images within the project. If you do so, be sure you replace the original images with some of the same size.

Have fun tweaking Scrum.

Jocly Playing Banners

The very best thing about developing on HTML 5 is that once your code runs, it can run in almost any situation: in Web pages, mobiles applications, Web TV, ... and as Web ad banners.

A few days ago, we released the Jocly Playing Banners which runs live computer vs computer games from within any regular web page. The code is invoked in a way very similar to AdSense:

<script type="text/javascript"><!--
joclypb_width = 300;
joclypb_height = 250;
//-->
</script>
<script type="text/javascript"
src="http://www.jocly.com/jocly/pb/show.js">
</script>

This results in (chosen game is random):

The banner size can be set to any value from parameters joclypb_width and joclypb_height.

If you like Jocly and want to add those banners to your site, we will gladly help you doing so.

Pages

Subscribe to RSS - mig's blog