Live4Sales, a Plant vs Zombies clone
Edit : Nape version
One week after the Osmos demo, I offer a new game made with the Citrus Engine based on an other popular game : Plant vs. Zombies!
If you have never played this game, try it now!
This is my clone made with the CE working as a browser game, air application, and on mobile devices :
The web demo. I didn’t make a preloader, to have the same source code between each version.
There is no end in the game to be used as a stress test!
Sources are available on the CE’s GitHub.
The story part
I was in Paris last weekend with Pierrick. We planned to make the Ludum Dare contest. The theme was evolution. We aren’t graphics designer, it’s (always) a problem. So we used graphics from a previous Pierrick’s project, the graphic designer was Lou Drouet. We thought to evolve ours shoppers into zombies to criticize brands addiction. Salers would throw later smartphones, etc… Nothing original, but funny. We didn’t finish the game because on Saturday’s night, my macbook pro died. It was baptised by beer and didn’t survive. After 4-5 years of excellent services, it was the best end that could happen to it : death on the battlefield! R.I.P. Since I need a laptop, I’ve bought a cheap Acer which do the job, I planned to buy an iMac later. No more Objective-C & Haxe NME for mobile at the moment. That’s it for my life part.
The case study
Plant vs Zombies is an interesting programming lesson since there are different ways to approach it. Some years ago
Emanuele Feronato has made several tutorials to develop this game. I really enjoy informations on his blog but don’t like his way of programming, not enough OOP. Anyway, I invite you to read his blog.
To develop Live4Sales, I used as usual the Citrus Engine. To detect collision I used Box2D physics engine ; Nape would work better but I wanted to test Box2D’s performances on mobile! Also you would probably not use a physics engine at all to make only simple collision detection. And finally I used Starling for the view renderer.
The physics engine is very useful for collision detection, and don’t forget : graphics always lie! The physic body is smaller than the graphics so we don’t have to manage/ignore layers superposition. Even if we use a physics engine, we need a grid to prevent two objects on the same case and to know if baddies are on a line. So basically we use 2 array.
Since there are lots of codes and class, I wouldn’t explain them all. We will focus on the game loop, a part of the hud (the drag & drop), the IA class and finally Box2D’s objects classes with some simple optimizations you have to take care!
Our GameState :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | package games.live4sales { import Box2DAS.Common.V2; import games.live4sales.assets.Assets; import games.live4sales.characters.SalesWoman; import games.live4sales.events.MoneyEvent; import games.live4sales.objects.Block; import games.live4sales.objects.Cash; import games.live4sales.runtime.BaddiesCreation; import games.live4sales.runtime.CoinsCreation; import games.live4sales.ui.Hud; import games.live4sales.utils.Grid; import starling.core.Starling; import starling.display.Image; import com.citrusengine.core.CitrusEngine; import com.citrusengine.core.StarlingState; import com.citrusengine.objects.CitrusSprite; import com.citrusengine.physics.Box2D; import com.citrusengine.view.starlingview.AnimationSequence; import com.citrusengine.view.starlingview.StarlingArt; /** * @author Aymeric */ public class Live4Sales extends StarlingState { private var _ce:CitrusEngine; private var _hud:Hud; private var _coinsCreation:CoinsCreation; private var _baddiesCreation:BaddiesCreation; public function Live4Sales() { super(); _ce = CitrusEngine.getInstance(); } override public function initialize():void { super.initialize(); Assets.contentScaleFactor = Starling.current.contentScaleFactor; var box2D:Box2D = new Box2D("box2D", {gravity:new V2()}); //box2D.visible = true; add(box2D); _hud = new Hud(); addChild(_hud); _hud.onIconePositioned.add(_createObject); _coinsCreation = new CoinsCreation(); addChild(_coinsCreation); StarlingArt.setLoopAnimations(["stand", "fire", "attack", "walk"]); var background:CitrusSprite = new CitrusSprite("background", {view:Image.fromBitmap(new Assets.BackgroundPng())}); add(background); _baddiesCreation = new BaddiesCreation(); } private function _createObject(name:String, posX:uint, posY:uint):void { if (Hud.money >= 50) { var casePositions:Array = Grid.getCaseCenter(posX, posY); if (casePositions[0] != 0 && casePositions[1] != 0) { if (name == "SalesWoman") { var saleswomanAnim:AnimationSequence = new AnimationSequence(Assets.getTextureAtlas("Objects"), ["fire", "stand"], "fire", 30, true); var saleswoman:SalesWoman = new SalesWoman("saleswoman", {x:casePositions[0], y:casePositions[1], group:casePositions[2], offsetY:-saleswomanAnim.height * 0.3, fireRate:1000, missileExplodeDuration:0, missileFuseDuration:3000, view:saleswomanAnim}); add(saleswoman); } else if (name == "Block") { var blockAnimation:AnimationSequence = new AnimationSequence(Assets.getTextureAtlas("Objects"), ["block1", "block2", "block3", "blockDestroyed"], "block1"); var block:Block = new Block("block", {x:casePositions[0], y:casePositions[1], group:casePositions[2], offsetY:-15, view:blockAnimation}); add(block); } else if (name == "Cash") { var cash:Cash = new Cash("cash", {x:casePositions[0], y:casePositions[1], group:casePositions[2], offsetY:-15, view:new Image(Assets.getAtlasTexture("cash", "Objects"))}); add(cash); } _ce.dispatchEvent(new MoneyEvent(MoneyEvent.BUY_ITEM)); } else trace('case not empty'); } else trace('no money'); } } } |
We create the Hud, the CoinsCreation & BaddiesCreation classes to create coins/baddies at runtime and we will create there our objects if we have money and if the case is empty.
Two importants CE’s properties
- the group property is used as a z-index sorter.
- thanks to the offsetX & offsetY properties even if your graphics is registred at the center point, you can move around!
The Hud :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | package games.live4sales.ui { import games.live4sales.events.MoneyEvent; import games.live4sales.assets.Assets; import games.live4sales.utils.Grid; import starling.display.Image; import starling.display.Sprite; import starling.events.Event; import starling.text.BitmapFont; import starling.text.TextField; import starling.textures.Texture; import com.citrusengine.core.CitrusEngine; import org.osflash.signals.Signal; import flash.display.Bitmap; /** * @author Aymeric */ public class Hud extends Sprite { [Embed(source="../embed/ArialFont.fnt", mimeType="application/octet-stream")] private var _fontConfig:Class; [Embed(source="../embed/ArialFont.png")] private var _fontPng:Class; static public var money:uint = 400; public var onIconePositioned:Signal; private var _ce:CitrusEngine; private var _grid:Grid; private var _vectorIcon:Vector.<Icon>; private var _backgroundMenu:Image; private var _iconSaleswoman:Icon; private var _iconCash:Icon; private var _iconBlock:Icon; private var _score:TextField; public function Hud() { _ce = CitrusEngine.getInstance(); onIconePositioned = new Signal(String, uint, uint); _grid = new Grid(); _vectorIcon = new Vector.<Icon>(3, true); _backgroundMenu = new Image(Assets.getAtlasTexture("background-menu", "Menu")); _iconSaleswoman = new Icon(Assets.getAtlasTexture("icon-saleswoman", "Menu")); _iconCash = new Icon(Assets.getAtlasTexture("icon-cash", "Menu")); _iconBlock = new Icon(Assets.getAtlasTexture("icon-block", "Menu")); _vectorIcon[0] = _iconSaleswoman; _vectorIcon[1] = _iconCash; _vectorIcon[2] = _iconBlock; var bitmap:Bitmap = new _fontPng(); var texture:Texture = Texture.fromBitmap(bitmap); var xml:XML = XML(new _fontConfig()); TextField.registerBitmapFont(new BitmapFont(texture, xml)); _score = new TextField(50, 20, "0", "ArialMT"); addEventListener(Event.ADDED_TO_STAGE, _addedToStage); } public function destroy():void { onIconePositioned.removeAll(); removeChild(_grid); for each (var icon:Icon in _vectorIcon) { icon.destroy(); removeChild(icon); } _vectorIcon = null; TextField.unregisterBitmapFont("ArialMT"); removeChild(_score); _ce.removeEventListener(MoneyEvent.BUY_ITEM, _changeMoney); } private function _addedToStage(evt:Event):void { removeEventListener(Event.ADDED_TO_STAGE, _addedToStage); addChild(_grid); _grid.visible = false; addChild(_backgroundMenu); _backgroundMenu.x = (480 - _backgroundMenu.width) / 2; addChild(_iconSaleswoman); _iconSaleswoman.name = "SalesWoman"; _iconSaleswoman.x = (480 - _iconSaleswoman.width) / 2 - 30; addChild(_iconCash); _iconCash.name = "Cash"; _iconCash.x = (480 - _iconCash.width) / 2 + 20; addChild(_iconBlock); _iconBlock.name = "Block"; _iconBlock.x = (480 - _iconBlock.width) / 2 + 70; for each (var icon:Icon in _vectorIcon) { icon.onStartDrag.add(_showGrid); icon.onStopDrag.add(_hideGridAndCreateObject); } _score.x = 150; _score.y = 3; addChild(_score); _score.text = String(money); _ce.addEventListener(MoneyEvent.BUY_ITEM, _changeMoney); _ce.addEventListener(MoneyEvent.PICKUP_MONEY, _changeMoney); } private function _changeMoney(mEvt:MoneyEvent):void { if (mEvt.type == "BUY_ITEM") money -= 50; else if (mEvt.type == "PICKUP_MONEY") money += 50; _score.text = String(money); } private function _showGrid():void { _grid.visible = true; } private function _hideGridAndCreateObject(name:String, posX:uint, posY:uint):void { _grid.visible = false; onIconePositioned.dispatch(name, posX, posY); } } } |
The Icon :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | package games.live4sales.ui { import starling.display.Image; import starling.events.Touch; import starling.events.TouchEvent; import starling.events.TouchPhase; import starling.textures.Texture; import org.osflash.signals.Signal; /** * @author Aymeric */ public class Icon extends Image { public var onStartDrag:Signal; public var onStopDrag:Signal; private var _dragging:Boolean = false; private var _posX:uint, _posY:uint; public function Icon(texture:Texture) { super(texture); onStartDrag = new Signal(); onStopDrag = new Signal(String, uint, uint); addEventListener(TouchEvent.TOUCH, _iconTouched); } public function destroy():void { removeEventListener(TouchEvent.TOUCH, _iconTouched); onStartDrag.removeAll(); onStopDrag.removeAll(); } private function _iconTouched(tEvt:TouchEvent):void { var touchBegan:Touch = tEvt.getTouch(this, TouchPhase.BEGAN); var touchMoved:Touch = tEvt.getTouch(this, TouchPhase.MOVED); var touchEnded:Touch = tEvt.getTouch(this, TouchPhase.ENDED); if (touchBegan) { _posX = this.x; _posY = this.y; _dragging = true; onStartDrag.dispatch(); } else if (touchMoved && _dragging) { this.x = touchMoved.globalX - this.width * 0.5; this.y = touchMoved.globalY - this.height * 0.5; } else if (touchEnded && _dragging) { _dragging = false; this.x = _posX; this.y = _posY; onStopDrag.dispatch(name, uint(touchEnded.globalX), uint(touchEnded.globalY)); } } } } |
In a game like Plant vs Zombies, the difficulty is progressive, there are more and more enemies. We use a master timer which will change the enemy’s speed creation after each 10s. It will be reduced of 500ms.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | package games.live4sales.runtime { import games.live4sales.assets.Assets; import games.live4sales.characters.ShopsWoman; import games.live4sales.utils.Grid; import com.citrusengine.core.CitrusEngine; import com.citrusengine.view.starlingview.AnimationSequence; import flash.events.TimerEvent; import flash.utils.Timer; /** * @author Aymeric */ public class BaddiesCreation { private var _ce:CitrusEngine; private var _timerProgression:Timer; private var _timerShopsWomen:Timer; public function BaddiesCreation() { _ce = CitrusEngine.getInstance(); _timerProgression = new Timer(10000); _timerProgression.start(); _timerProgression.addEventListener(TimerEvent.TIMER, _progressionDifficulty); _timerShopsWomen = new Timer(4000); _timerShopsWomen.start(); _timerShopsWomen.addEventListener(TimerEvent.TIMER, _tick); } public function destroy():void { _timerProgression.stop(); _timerProgression.removeEventListener(TimerEvent.TIMER, _progressionDifficulty); _timerShopsWomen.stop(); _timerShopsWomen.removeEventListener(TimerEvent.TIMER, _tick); } private function _progressionDifficulty(tEvt:TimerEvent):void { _timerShopsWomen.removeEventListener(TimerEvent.TIMER, _tick); var delay:uint = _timerShopsWomen.delay - 500; if (delay < 500) delay = 500; _timerShopsWomen = new Timer(delay); _timerShopsWomen.start(); _timerShopsWomen.addEventListener(TimerEvent.TIMER, _tick); } private function _tick(tEvt:TimerEvent):void { var casePosition:Array = Grid.getBaddyPosition(0, Grid.getRandomHeight()); var shopsWomanAnim:AnimationSequence = new AnimationSequence(Assets.getTextureAtlas("Objects"), ["walk", "attack"], "walk"); var shopswoman:ShopsWoman = new ShopsWoman("shopswoman", {x:480, y:casePosition[1], group:casePosition[2], offsetY:-shopsWomanAnim.height * 0.3, view:shopsWomanAnim}); _ce.state.add(shopswoman); shopswoman.onTouchLeftSide.add(_endGame); Grid.tabBaddies[casePosition[2]] = true; } private function _endGame():void { trace('game over'); } } } |
Now let’s start some Box2D stuff. The Citrus Engine has already lots of Box2D objects pre-built, we will extend them.
Example the SalesWoman which is just similar to a Cannon :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | package games.live4sales.characters { import Box2DAS.Dynamics.ContactEvent; import games.live4sales.assets.Assets; import games.live4sales.utils.Grid; import games.live4sales.weapons.Bag; import starling.display.Image; import com.citrusengine.objects.platformer.box2d.Cannon; import flash.events.TimerEvent; import flash.utils.Timer; /** * @author Aymeric */ public class SalesWoman extends Cannon { public var life:uint = 2; private var _timerHurt:Timer; public function SalesWoman(name:String, params:Object = null) { super(name, params); _timerHurt = new Timer(1000); _timerHurt.addEventListener(TimerEvent.TIMER, _removeLife); } override public function destroy():void { _fixture.removeEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact); _fixture.removeEventListener(ContactEvent.END_CONTACT, handleEndContact); _timerHurt.removeEventListener(TimerEvent.TIMER, _removeLife); _timerHurt = null; super.destroy(); } override public function update(timeDelta:Number):void { super.update(timeDelta); if (life == 0) { kill = true; var tab:Array = Grid.getCaseId(x, y); Grid.tabObjects[tab[1]][tab[0]] = false; } if (Grid.tabBaddies[group]) _firing = true; else _firing = false; } override protected function createFixture():void { _fixture = _body.CreateFixture(_fixtureDef); _fixture.m_reportBeginContact = true; _fixture.m_reportEndContact = true; _fixture.addEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact); _fixture.addEventListener(ContactEvent.END_CONTACT, handleEndContact); } protected function handleBeginContact(cEvt:ContactEvent):void { if (cEvt.other.GetBody().GetUserData() is ShopsWoman) { if (!_timerHurt.running) _timerHurt.start(); } } protected function handleEndContact(cEvt:ContactEvent):void { if (cEvt.other.GetBody().GetUserData() is ShopsWoman) { if (_timerHurt.running) _timerHurt.stop(); } } private function _removeLife(tEvt:TimerEvent):void { life--; } override protected function _fire(tEvt:TimerEvent):void { if (_firing) { var missile:Bag; if (startingDirection == "right") missile = new Bag("Missile", {x:x + width, y:y, group:group, width:missileWidth, height:missileHeight, offsetY:-30, speed:missileSpeed, angle:missileAngle, explodeDuration:missileExplodeDuration, fuseDuration:missileFuseDuration, view:new Image(Assets.getAtlasTexture("bag", "Objects"))}); else missile = new Bag("Missile", {x:x - width, y:y, group:group, width:missileWidth, height:missileHeight, offsetY:-30, speed:-missileSpeed, angle:missileAngle, explodeDuration:missileExplodeDuration, fuseDuration:missileFuseDuration, view:new Image(Assets.getAtlasTexture("bag", "Objects"))}); _ce.state.add(missile); } } override protected function _updateAnimation():void { if (_firing) _animation = "fire"; else _animation = "stand"; } } } |
Now let’s start talking about some Box2D optimizations :
A missile should only collide with an enemy, we can do it easily :
protected function handleBeginContact(cEvt:ContactEvent):void { var other:Box2DPhysicsObject = cEvt.other.GetBody().GetUserData(); if (!(other is ShopsWoman)) cEvt.contact.Disable(); } |
However disabling a contact this way is expensive if we have lots of missiles & contacts. Imagine, a missile crosses through an other SalesWoman, Cash and Block… So we use categoryBits & maskBits filters! Then they will never collide! With the Citrus Engine we can set up filters easily without bits manipulation. Also ShopsWoman shoudln’t collide between them.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | package games.live4sales.characters { import Box2DAS.Common.V2; import Box2DAS.Dynamics.ContactEvent; import games.live4sales.objects.Block; import games.live4sales.objects.Cash; import games.live4sales.utils.Grid; import games.live4sales.weapons.Bag; import com.citrusengine.objects.Box2DPhysicsObject; import com.citrusengine.physics.Box2DCollisionCategories; import org.osflash.signals.Signal; /** * @author Aymeric */ public class ShopsWoman extends Box2DPhysicsObject { public var speed:Number = 0.7; public var life:uint = 4; public var onTouchLeftSide:Signal; private var _fighting:Boolean = false; public function ShopsWoman(name:String, params:Object = null) { super(name, params); onTouchLeftSide = new Signal(); } override public function destroy():void { _fixture.removeEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact); _fixture.removeEventListener(ContactEvent.END_CONTACT, handleEndContact); onTouchLeftSide.removeAll(); super.destroy(); } override public function update(timeDelta:Number):void { super.update(timeDelta); if (!_fighting) { var velocity:V2 = _body.GetLinearVelocity(); velocity.x = -speed; _body.SetLinearVelocity(velocity); } if (x < 0) { onTouchLeftSide.dispatch(); kill = true; } if (life == 0) { kill = true; Grid.tabBaddies[group] = false; } else { Grid.tabBaddies[group] = true; } updateAnimation(); } override protected function defineBody():void { super.defineBody(); _bodyDef.fixedRotation = true; } override protected function defineFixture():void { super.defineFixture(); _fixtureDef.friction = 0; _fixtureDef.filter.categoryBits = Box2DCollisionCategories.Get("BadGuys"); _fixtureDef.filter.maskBits = Box2DCollisionCategories.GetAllExcept("BadGuys"); } override protected function createFixture():void { super.createFixture(); _fixture.m_reportBeginContact = true; _fixture.m_reportEndContact = true; _fixture.addEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact); _fixture.addEventListener(ContactEvent.END_CONTACT, handleEndContact); } protected function handleBeginContact(cEvt:ContactEvent):void { var other:Box2DPhysicsObject = cEvt.other.GetBody().GetUserData(); if (other is SalesWoman || other is Block || other is Cash) _fighting = true; else if (other is Bag) { life--; cEvt.contact.Disable(); } } protected function handleEndContact(cEvt:ContactEvent):void { var other:Box2DPhysicsObject = cEvt.other.GetBody().GetUserData(); if (other is SalesWoman || other is Block || other is Cash) _fighting = false; } protected function updateAnimation():void { _animation = _fighting ? "attack" : "walk"; } } } |
Now the bags. They are just a missile with categoryBits & maskBits filters. However the CE is prebuilt as a platformer engine, it means that Missiles remove Box2D gravity this way :
override public function update(timeDelta:Number):void { var removeGravity:V2 = new V2(); removeGravity.subtract(_box2D.world.GetGravity()); removeGravity.multiplyN(body.GetMass()); _body.ApplyForce(removeGravity, _body.GetWorldCenter()); } |
In our game we don’t have gravity so we just remove this unnecessary & expensive calculation! And finally Missile are defined as bullet and use Continous Collision Detection, this is very expensive. Since our missiles aren’t fast we don’t need that.
The Bag class :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | package games.live4sales.weapons { import Box2DAS.Common.V2; import Box2DAS.Dynamics.ContactEvent; import com.citrusengine.objects.platformer.box2d.Missile; import com.citrusengine.physics.Box2DCollisionCategories; /** * @author Aymeric */ public class Bag extends Missile { public function Bag(name:String, params:Object = null) { super(name, params); } override public function update(timeDelta:Number):void { if (!_exploded) _body.SetLinearVelocity(_velocity); else _body.SetLinearVelocity(new V2()); if (x > 480) kill = true; } override protected function handleBeginContact(cEvt:ContactEvent):void { explode(); } override protected function defineBody():void { super.defineBody(); _bodyDef.bullet = false; _bodyDef.allowSleep = true; } override protected function defineFixture():void { super.defineFixture(); _fixtureDef.filter.categoryBits = Box2DCollisionCategories.Get("Level"); _fixtureDef.filter.maskBits = Box2DCollisionCategories.GetAllExcept("Level"); } } } |
I’m pretty sure that Box2D ninja user will find other optimizations, please share them.
The example is over now. Again the hardest part is to find good parameters & balance to have the best user experience. To do that you have to try your game again & again and when you think it is ok : let’s an other guy try it… then restart your balancing!
That was an other non platformer game example for the CE! Cheers!
Thx for cool demo, any info on the performance of Box2D on devices with this example? On your previous posts, I thought you mentioned the Nape was the way to go on mobile devies.