News

Alquerque + UCT for all checkers

Alquerque + UCT for all checkers

We release today a gorgeous 3D version of the Alquerque.

You can play it here , or just below in this post.

We also took this oppotunity to connect all the checkers to UCT AI. So watch out, this is gonna be harder and faster ;)

Curious about other checker rules? Did you try the astonishing Turkish "Dama"?

Here is the list of the variants available on jocly:

  1. International draughts (10x10 board)
  2. Brazilian draughts (8x8 board)
  3. German draughts (8x8 board)
  4. English draughts (8x8 board)
  5. Tukish draughts "Dama" (8x8 board, no diagonal moves)
  6. Thai draughts (8x8 board)
  7. Kids draughts (6x6 board)

 

 

Play Alquerque below (you can embed the same way the game in your blog or site)

UCT and Artificial Intelligence on Jocly

UCT and Artificial Intelligence on Jocly

Jocly is a big and complex project that aims to provide abstract strategy games with a number of features common to all those games.
 
As for any project, our development resources are limited and we often reach the point where we ask “what do we do next in the project ?”. Sometimes we answer with “let’s add a new game” to improve our game range diversity, or “add this feature” like we recently did with the addition of WebGL and WebRTC, or it might be “improve this” because we estimate that given the overall Jocly quality, that particular feature became a weakness.
 
Currently, we identified two main points where we are below Jocly standards:

  1. the overall User Interface (not the games but the application itself)
  2. the Artificial Intelligence when playing against the computer

We are working actively on the User Interface and we will come back to you on this in the near future.
 
Regarding the AI, the main criticisms are “it’s too slow on my device” or “it does not play well enough”, so we did our best to address those issues.

Introduction to Articial Intelligence in games

You can see a game solving problem like this: given a current board state, you consider all moves you can possibly play. This gives you a root with a number of branches at the end of each you get another board state as a leaf.

 
 
 
Figure 1 - note that traditionally, the tree is represented upside-down with the root at the top and the leaves at the bottom

 
 
However, in each board state leaf (unless it’s terminal, i.e the game is over), the opponent can play moves that lead to other board states, and so on.
 

 

Figure 2

 
 
Obviously, if the total game tree is not infinite, it is huge and there is no way we can explore it all (except at the end of the game). So the exploration must reach a limit, like having a maximum depth to be visited.

Minimax

When we are at the leaf (we don’t want or can explore the states below), we can evaluate the board states. For instance, in checkers, you can count the remaining pieces and do:

evaluation = <white pieces count> - <black pieces count>

(Of course, you can be smarter in including many other criteria in your evaluation)
So if the higher the evaluation is, the best it is for the whites, the lower is best for the blacks.

 
 
Figure 3

 
For a given board state S, white will look at the child nodes and will prefer the move that leads to the highest evaluation, so the evaluation of S is the maximum of its children evaluation. If it’s black to play, the evaluation is the minimum of its children.
 
So each node in the tree can be evaluated by the maximum child node evaluation, the minimum child node evaluation or a static evaluation if we don’t want to explore the children.

This method of choosing the (presumably) best computer move is known as Minimax and has been found in the 20s.

More on Minimax.
 

Alpha-beta

Since the very first days of Jocly, Minimax is used with an improvment called Alpha-Beta
 
Given that we explore the tree in the following order:
 

 

Figure 4

You can notice that after exploring node 11, we can realize that there is no need to explore node 12.

 

Figure 5

Indeed, whatever the evaluation of node 12, if it’s greater than 4, it won’t be propagated to the parent node (because it’s a ‘min’ node) and if it’s less than 4, it won’t propagate to the grand-parent node (because we already have a 5 and the grand parent is a ‘max’ node).
 
As a result, there is an entire branch we don’t need to explore because we know its outcome won’t affect the final decision.
 
Alpha-beta may not be very spectacular in this example, when exploring large trees, it has drastic effects and can easily remove from the search 80% of the entire tree.
 
More information about Alpha-Beta pruning.
 
The bad thing about alpha-beta is that you don’t really know when to stop the tree exploration. You can say, i go down all branches (except the ones that have been alpha-beta pruned) to depth 5. In Jocly, we also assign to a node a potential number of child nodes to be explored. If a node has 4 children, each child inherits ¼ of its parent’s potential. When the potential goes below 1, we stop the exploration. This method allows to explore deeper situations with forced moves for instance.

However, the main problem is that we waste time exploring branches below moves that are unlikely to be played because they are simply bad moves (but we don’t know that yet).

UCT

Jocly now implements the UCT AI algorithm as an alternative to alpha-beta. It produced spectacular results with the newly released Margo game and we plan to use this AI on all other Jocly games.
 
UCT is fundamentally different from Alpha-Beta in such that instead of exploring the tree in depth, it proceeds by iteration in order to build the tree progressively, adding one or more branches at each iteration.
 
The benefits are highly valuable:

  1. we can give a limited duration for a computer to decide for a move: at the end of the timer the move is picked.
  2. and/or on a CPU limited device, we can give a maximum number of iteration
  3. and/or on a memory limited device, we can give a maximum number of nodes to be created 

And the best of all is that from the tests we run, the quality of the picked move is much better than the regular alpha-beta algorithm for equivalent computing times.
 
UCT works by growing the tree with successive iterations:
 

Figure 6

 
 
Obviously, the branch that is expanded depends on the apparent quality of the corresponding move. This allows to explore deeper branches where the game is likely to go in the next moves.
 
If we only the best node to expand, we will completely miss situations where a sacrifice leads to a better board state. A sacrifice move has a low apparent quality, so we won’t explore the branches below that would have shown a win of the game.  
 
Fortunately, UCT has a mechanism to balance this behavior. It’s called UCB (for Upper Confidence Bound).
 
When walking down the tree, we pick the child node that has the best UCB. The UCB value involves the number of visits made to this node in such a way that when a node is not frequently visited, its UCB increases, so that if at some point its UCB become bigger than its brother nodes, it will be picked next time.
 
For information, the UCB formula is:
 

UCB = V + C x sqrt ( ln (node parent visits) / ( node visits) )

Where V is the intrinsic value of the node (obtained from static evaluation if leaf node, or the value that has been minimaxized from the child nodes), and C, a constant value that has to be approximated manually for a given game type.
 
When a leaf node has been reached, we decide whether to evaluate the node or to expand it (i.e create its child nodes). The node evaluation is then propagated back to the upper nodes using the minimax method (since we now run minimax from bottom to top, it’s very fast).
 
In regular UCT, once you have reached a leaf node, you may run a ‘playout’ from the current board state in order to improve the node static evaluation. A playout means finishing the game by playing random or heuristic oriented moves to the end or at least for a number of moves. Once you have run enough playouts on a node, you may decide at the next that it’s worth expanding the node. We have the code in place for doing running playouts, but our experiments on Margo showed that the CPU time spent in playouts was better used at expanding the tree, so we configure the game levels in order to run no playouts. Maybe it will be worth running playouts on other games where generating the possible moves is less expensive than on Margo.
 
For more information about UCT, check this site (note it’s written by Cameron Browne, the inventor of the Margo game).
 

A little bit of genetic

Once a game is implemented you generally ends up with a number of parameters to be tuned for the evaluation function. For instance, in checkers, you can evaluate your pieces like this:  

evaluation = <W soldiers> + P x <W kings> - <B soldiers> - P x <B kings>

You can easily count the number of soldiers and kings, but what is the value of P ? Of course, you can try to guess something that looks reasonable, but when you have a number of parameters to setup and the optimal value of each depends on the value of others, the tuning quickly becomes a nightmare. For instance, in Margo, we use 11 different constants to generate an evaluation of a board.
 
Plus, when using the UCT AI, there are a number of other parameters that influence the computer play, starting with the C parameter of the UCB formula.
 
To address this issue we had from the beginning of Jocly, we developed a tool using genetic programming to find the most suitable parameters set.
 
A genetic algorithm mimics the real life evolution rules: only the best ones survive and reproduce.
 
The idea is to have individuals with a set of chromosomes, each of those representing a parameter to be tuned (which can be an integer, float, boolean or discrete value). You start with a population of individuals whose chromosome values have been picked randomly. At each iteration, you play a big tournament between all the individuals. You keep the ones that perform the best and kill the others. You breed randomly the remaining population by combining their chromosomes and allowing some mutations (a chromosome value may be altered). After a number of iterations, you can pick the best performer with a reasonable confidence that it represents a close-to-best set of game parameters.
 
This simple algorithm works remarkably well. The bad point being the time it takes to run. Measuring the fitness of each individual requires running a large number of games and this takes quite a long time. It is not rare that we have to run the genetic selector over 24 hours to get a satisfying result.

 

Margo

Margo

Margo is similar to Go but is exploring the 3rd dimension. You play with marbles which may stack upwards.

Watch the video below to understand the main rules.

The game is availbale in 2D and 3D.

Margo is a very strategic game. To go deeper you can download the full Margo book for free (179 pages, PDF) by the author of the game Cameron Browne, or order a print version.

5 board sizes are available, you can play here:

 

 

Kids Draughts with the flying turtles

Kids draughts are back with a new "3D turtles" skin.

 

This fun design has been made for kids 6x6 board but is also available for 8x8 and 10x10 boards. Just go to the view options and choose your prefered skin.

 

 

Available features:

- 2D and 3D skins

- Chat room

WebRTC video communication

- computer, live and turnbased modes

 

X Men's Morris

X Men's Morris

It's a very ancient game you can play on several board sizes: 3, 6, 7, 9 and 12 Men's Morris.

And you can now play it in the stars ;)

The rules:

Variants:

3 Men's Morris, 6 Men's Morris, 7 Men's Morris, 9 Men's Morris, 12 Men's Morris + fly modes for 9 Men's Morris Fly, 12 Men's Morris Fly.

Available features:

- 2D and 3D skins

- Chat room

- WebRTC video communication

- computer, live and turnbased modes

Turkish Draughts, aka Dama

Turkish Draughts, aka Dama

Do you know this amazing variant of the draughts? In the turkish draughts, you don't move your pieces on diagonals but forward or sideways.

It is played on a 8x8 board. You start with 2 rows of 8 pieces (2nd & 3rd row).

It really changes games strategy. You should try, it's here: http://www.jocly.com/jocly/hubquick/turkish-draughts

Available features:

- 2D and 3D skins

- Chat room

- WebRTC video communication

- computer, live and turnbased modes

Enjoy!

 

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);
	    }
	});

New game: Mana

New game: Mana

Mana is  a game invented by Claude Leroy in 2005.

We designed a 2D version which looks a bit like the original game, and for the 3D version, we went a bit further in asian mood.

You can play it on the Mana Quick Hub with direct access to game against the computer or against the 1st availbale connected human player. Don't forget to authorize access to your webcam if you want to try our new experimental feature: face detection! (Chrome only)

Players live video chat is out: WebRTC rocks!

Players live video chat is out: WebRTC rocks!

Jocly is excited to release one of the very first fully functional WebRTC applications (a real one, not simply a demo ;) ) : players video chat and its 3D environment integration (WebGL).

And it looks like this:

From the very first days of Jocly, we were thinking of such a feature: dive into the game and be able to discuss with your opponent. It really changes the atmosphere of a game, it's so much more friendly.

WebRTC is one of the very latest web technologies that allows you to establish a peer to peer video connection inside your browser, without any other software needed. Maybe you are used to video conferences with colleagues, friends or family through "Skype like" applications. With WebRTC, you can do it in any web page.

WebRTC is very new and is not yet available on all your common web browser releases. It is still an "early adopter" thing ;)

So if you want to try, we decided to deploy the functionality on Jocly, available to anyone today, but you will have to install specific releases of Firefox or Chrome to make it work: 

Chrome browser logo WebRTC is available in Chrome's stable version.
Firefox browser logo WebRTC is available in Firefox's Nightly version

Basic feature

WebRTC is available for live games. All games can have access to video communication in the players' boxes. The video takes the place of your avatars.

If you and your opponent have a compliant environment, Jocly will propose to start your webcam and microphone at the beginning of the game.

On Chrome it looks like this:

On Firefox it looks like that:

Each video stream has its WebRTC controls. Right click on the video if you want to mute a stream, for instance your own sound to avoid unwanted echos.

The video of the players in the headband can be enlarged by clicking on the  button in the player.

To know if you have the right configuration, or find opponents who have it too, look at the "Who's there" box. A green camera is displayed if the WebRTC is available for the user. This info is also displayed in the waiting games list.

Advanced feature

For some games, we developped more immersive experiences by putting you inside the 3D environment. We started with the games we have a WebGL interface for: Yohoho, Chess and Draughts. And it rocks :)

Note: there is a bug in Firefox Nightly that doesn't allow this WebGL integration so for the moment, Chrome is better.

There is no limit :)

Sharing our experience

We have been through a lot of difficulties and we decided to share some of our experience. A more technical post describes how we did it and shares some parts of the code. Of course we are also here to learn from others, so do not hesitate to exchange with us. This is just the beginning :)

(A few more pics on our Pinterest)

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.

Pages

Subscribe to RSS - blogs