Making a 2D Platformer

This is an intermediate tutorial This is an intermediate tutorial. < Making a Health Pickup This tutorial is part of a series on scripting. Return to tutorial index



In the last tutorial, we covered making a health pack in ROBLOX. In this tutorial we will cover getting player input and customizing character controls. To do this, we will make a simple camera and control system for a 2D platformer.

2D Platformer Image00.png

Before we get into the actual code of the platformer, it is important to understand how ROBLOX games are structured. When a ROBLOX game starts up, a ROBLOX server starts up a copy of the game. This copy of the game is called the Server. This server is responsible for keeping track of all of the parts and players in the game. When a player joins the game, they also run a copy on their local machine; The player’s version of the game world is called the Client.

Whenever any change is made on the Server, such as a part moving, this information is sent to all of the Clients automatically. The Scripts we have been using thus far run exclusively on the Server. This works when we need to run code that affects the server, but what about camera and input? These are things that affect individual players but not the server. In order to write code for individual players, we need to use a LocalScript. This is a special type of script that runs on the Client machine, not the server.

Camera[edit]

There are several styles of cameras for 2D games, but the one we will use is very simple: If the player moves too far to the right of the camera, then the camera will move to the right. Otherwise, the camera stays stationary.

Setup[edit]

The first thing to set up will be the camera itself. We need to overwrite the ROBLOX default camera script in order to write our own. To do this, insert a LocalScript into StarterPlayerScripts and rename it CameraScript.

2D Platformer Image02.png

Now we need to initialize where the camera starts.

local cameraHeight = 12
local cameraZOffset = 20
 
local camera = game.Workspace.CurrentCamera
local player = game.Players.LocalPlayer
 
local function setupCamera()
	camera.CFrame = CFrame.new(Vector3.new(0,cameraHeight,cameraZOffset), Vector3.new(0,cameraHeight,0))
end
setupCamera()
player.CharacterAdded:connect(setupCamera)

We first set up variables for the height of the camera as well as how far back we want it to be from the plane the player is walking along. We put these variables at the top to make them easy to find, in case we need to tune them later.

We then get variables for the camera and the player. In a LocalScript, game.Workspace.CurrentCamera returns the camera of the local player, and game.Players.LocalPlayer gets the local player.

Next, we make a function to set the initial position of the camera. We will need this when the player first enters the game as well as every time their character respawns. In this function we simply set the CFrame of the camera.

A CFrame (shorthand for CoordinateFrame), stores both the position of an object and its orientation. You often see them used to manipulate Parts, but a player’s camera also has a CFrame. While there are several ways to make a CFrame, we will use the .new function of CFrame that takes two Vector3’s as arguments. The first Vector3 determines the position of the CFrame. The second Vector3 sets where the CFrame is pointing to.

We call our function immediately after declaring it so the camera gets put in the right position as soon as the player joins. We also bind our function to the CharacterAdded event of the player so it gets called whenever the player character respawns.

Moving the camera[edit]

Now we need to move the camera as the player’s character moves to the right. When dealing with moving cameras, it is important to understand how rendering works in ROBLOX. The camera defines where exactly in the 3D scene we want the player to see, which then determines what renders on the screen. The ROBLOX engine refreshes this rendering about every 1/60th of a second; Rendering this quickly means the viewer sees a smooth transition between frames. To make sure the camera moves smoothly, we need to make sure the camera’s position is updated every time this render refresh occurs. Fortunately, this is very easy with a function called BindToRenderStep.

local cameraHeight = 12
local cameraZOffset = 20
local cameraXChase = 10local cameraSpeed = .25 
local camera = game.Workspace.CurrentCamera
local player = game.Players.LocalPlayer
local RunService = game:GetService('RunService') 
local function setupCamera()
	camera.CFrame = CFrame.new(Vector3.new(0,cameraHeight,cameraZOffset),
										Vector3.new(0,cameraHeight,0))
end
setupCamera()
player.CharacterAdded:connect(setupCamera)
 
local function onUpdate()	if player.Character and player.Character:FindFirstChild('Torso') then		local playerX = player.Character.Torso.Position.X		local cameraX = camera.CFrame.p.X 		if cameraX - cameraXChase < playerX then			camera.CFrame = camera.CFrame + Vector3.new(cameraSpeed, 0, 0)		end	endend RunService:BindToRenderStep('Camera', Enum.RenderPriority.Camera.Value, onUpdate)

We’re doing a couple things here, so let’s go through them one at a time. We first declare a few more variables. The first, cameraXChase represents how far the player can be from the camera in the X dimension before the camera starts moving. cameraSpeed is how far the camera will move every render step to try to catch up to the player.

The next variable we declare is for RunService which we only need for its BindToRenderStep function. Skipping to the end of the script, notice that we call BindToRenderStep with three values. The first is a name for this binding, the second is when during the render step to execute, and lastly the function we want to bind - in this case our custom function, onUpdate.

onUpdate does a couple of things. First, it checks to see if the player has a character model and if that model has a part called Torso. If it didn’t have these things, the player is likely either spawning or leaving the game. In either of those cases, we don’t really need the camera to do anything. But if the player does have a character, then we get the X position of the character and X position of the camera. We then check how far the character is from the camera. If the character is too close to the camera then we update the CFrame of the camera to the right.

2D Platformer Image01.png

In this case the camera shouldn’t move.

2D Platformer Image04.png

In this case the camera should move.

Controls[edit]

Now that the camera is locked to the side view, let’s add custom controls to move the character only in the XY plane. This code will be dealing with input so it will need to go into a LocalScript. Insert another LocalScript into StarterPlayerScripts and rename it to ControlScript. Just like how inserting a LocalScript called CameraScript overwrites the default camera controls, ControlScript will overwrite the default character controls. Your StarterPlayerScripts should now look like this:

2D Platformer Image03.png

Binding Controls[edit]

In our LocalScript we will need to make several functions and bind them to player input. There are a couple ways to do this, but one of the most convenient is to use ContextActionService. This service lets you bind multiple sources of input to a single function. This is very handy if you want your game to handle the various input devices that ROBLOX supports (keyboard, touchscreen, gamepad, etc).

Before we write the code that actually moves the player character, let’s first setup the control bindings. In our new LocalScript, add the following:

local player = game.Players.LocalPlayer
local RunService = game:GetService('RunService')
local ContextActionService = game:GetService('ContextActionService')
 
local function onLeft(actionName, inputState)
 
end
 
local function onRight(actionName, inputState)
 
end
 
local function onJump(actionName, inputState)
 
end
 
local function onUpdate()
 
end
 
RunService:BindToRenderStep('Control', Enum.RenderPriority.Input.Value, onUpdate)
 
ContextActionService:BindAction('Left', onLeft, true, 'a', Enum.KeyCode.Left, Enum.KeyCode.DPadLeft)
ContextActionService:BindAction('Right', onRight, true, 'd', Enum.KeyCode.Right, Enum.KeyCode.DPadRight)
ContextActionService:BindAction('Jump', onJump, true, 'w', Enum.KeyCode.Space, Enum.KeyCode.Up, Enum.KeyCode.DPadUp, Enum.KeyCode.ButtonA)

Just like in our CameraScript we are creating variables for the player and for RunService. We also need a variable for ContextActionService. We then create a function for each action the player can perform in our game: onLeft, onRight, and onJump. These functions take two arguments: the action name and the state of the input that called the function.

Even though we will only be using the inputState parameter for the input functions, we still need to include the action name as the first parameter as the event that calls these functions will be including both. For example, consider the following code:
local function test1(a, b)
	print(b)
end
 
local function test2(b)
	print(b)
end
 
test1("dog", "cat")
test2("dog", "cat")
If we want the function that prints out cat in this case, we would need to use test1 as cat is passed in as the second parameter.

Like in CameraScript, we also create an update function that we bind to RenderStepped. This function will actually do the moving of the character after we handle the input. Make sure the priority of the binding is set to Input (we used Camera before).

Next, we bind our three input functions using BindAction. BindAction takes several arguments. The first is the name of the action. The second is the function you want to bind. The third argument is whether you want to automatically create a button for this action on touch devices such as phones and tablets. Lastly, you can include a list of inputs you want to trigger the action. If the input is just a letter key on the keyboard you can include it as a string surrounded by quotes (""). Otherwise you can use Enum.KeyCode to specify special keys and buttons.

Moving the Character[edit]

Now let’s add the code to move the character. Again, we are going to do the actual moving in the onUpdate function. All of the input functions will set variables onUpdate will use to determine how to move the character.

local player = game.Players.LocalPlayer
local RunService = game:GetService('RunService')
local ContextActionService = game:GetService('ContextActionService')
 
local jumping = falselocal leftValue, rightValue = 0, 0 
local function onLeft(actionName, inputState)
	if inputState == Enum.UserInputState.Begin then			leftValue = 1	elseif inputState == Enum.UserInputState.End then		leftValue = 0	endend
 
local function onRight(actionName, inputState)
	if inputState == Enum.UserInputState.Begin then		rightValue = 1	elseif inputState == Enum.UserInputState.End then		rightValue = 0	endend
 
local function onJump(actionName, inputState)
	if inputState == Enum.UserInputState.Begin then		jumping = true	elseif inputState == Enum.UserInputState.End then		jumping = false	endend
 
local function onUpdate()
	if player.Character and player.Character:FindFirstChild('Humanoid') then		if jumping then			player.Character.Humanoid.Jump = true		end		local moveDirection = rightValue - leftValue		player.Character.Humanoid:Move(Vector3.new(moveDirection,0,0), false)	endend
 
RunService:BindToRenderStep('Control', Enum.RenderPriority.Input.Value, onUpdate)
 
ContextActionService:BindAction('Left', onLeft, true, 'a', Enum.KeyCode.Left, Enum.KeyCode.DPadLeft)
ContextActionService:BindAction('Right', onRight, true, 'd', Enum.KeyCode.Right, Enum.KeyCode.DPadRight)
ContextActionService:BindAction('Jump', onJump, true, 'w', Enum.KeyCode.Space, Enum.KeyCode.Up, Enum.KeyCode.DPadUp, Enum.KeyCode.ButtonA)

We first set up three new variables: jumping, leftValue, and rightValue. jumping is just a boolean: the player is either going to be jumping or they aren’t. Left and right are a little more complicated though. When the player holds left, the character should obviously move left, but when the player presses right while left is held down, the character should stop. If the player releases either left or right, then the character should move in the direction of the key that is still being held. Making a variable for each direction will be helpful as you will see in the onUpdate function.

You may have noticed that leftValue and rightValue were written in a strange way. There are several ways to declare variables in Lua, and this style is useful when you want to use fewer lines of code to set variables. Note that the line
local leftValue, rightValue = 0, 0

could just as easily be written as

local leftValue = 0
local rightValue = 0

In each of the input functions, we check the inputState, which just says what type of input event triggered the function. In this case, we only care about when the player presses a key (Enum.UserInputState.Begin) and when they release the key (Enum.UserInputState.End). In onLeft and onRight if the matching key is pressed down then we set the corresponding variable to 1. If the key was released the value is set to 0. In onJump we set the jumping variable to true when the jump input starts, and to false when the input ends.

In onUpdate, we first check if the player character exists and if it’s Humanoid is intact. If so, we check if the jumping variable was set to true. If it is, then we set the Jump property of the player’s Humanoid to true.

You don’t have to set the Jump property to false at any time. The ROBLOX engine will handle that for you automatically.

onUpdate next figures out which direction to move the character by subtracting leftValue from rightValue. Remember that these variables can be set individually. If both are held down, the result will be 0 meaning the character won’t move. If one of the keys is held down, then one of the values will be 1, and the difference will be either 1 or -1. We then call the Move function on the character’s Humanoid, passing in the difference we just calculated. This function will force a character to move in the direction we supply. Since we only set a value in the X dimension, the character will only be able to move along that axis.

Conclusion[edit]

With the combination of camera and control scripts we now can configure our ROBLOX game to operate as a 2D platformer. If you want to add or expand to the system we set up here, be mindful of when you use Scripts and LocalScripts. Remember, if there is a gameplay mechanic that will affect all of the players (such as a trap, moving platforms, etc), then that should be implemented in a Script. If you are adding new controls or player specific feedback (such as a dash move, double jump, camera fade effects), those should go in a LocalScript.