How to make a flying Starship

Here is video of what this tutorial explains how to make:

Limitations:

  • there is no way to roll camera ATM (ship techically possible, but it does not look the same)

  • movement have slight jitter

Create template of Pilot

Go to Library, select Templates, click on Player and choose Create Template

Name new template Pilot and open that created template

Now add new Script on the Pilot template

Name the new script also Pilot for simplicity.

Normally you would want to break your scripts into smaller functions, but for the purpose of this tutorial let’s keep it a bit simpler.

Create template of Starship

Grab locator and drop on the map

Kitbash some spaceship from meshes. You can search by tag “Outer Space” for example

For example drop some spaceship wings under locator and place them nicely

This is coding tutorial, so I will not explain kitbashing, just be creative :slight_smile:

While locator is selected choose Template and Create New Template

Give template name Starship

Now go to template Starship and also add new script Starship to it. Now Pilot template has Pilot script and Starship have Starship script.

Make pilot to track ship

After you have created script, fast way to open it - is to use shortcut Ctrl+O and start entering name of created script, search should show you the script.

Let’s open Pilot script.

Pilot script

In the opened script you can write some code, which would work later when game starts.

Pilot will need link to the ship, so let’s add property inside of Pilot.Properties.

You can read more about properties in this tutorial: Adding editor properties to Crayta scripts | Crayta Developer Area

For now let’s make our properties like so:

Pilot.Properties = {
    {name = "ship", type = "entity"},
}

Additionally let’s modify Init function so that we have a link to self:GetEntity() in self.me like this

function Pilot:Init()
    self.me = self:GetEntity()
end

This is initial setup I often do in scripts, because it is easier to refer to self.me rather than calling method and it should also work faster.

self.me keeps link to entity script attached to. In this case this would be player character.

Let’s also add a LocalInit with same code


function Pilot:LocalInit()
    self.me = self:GetEntity()
end

In contrast to Init, LocalInit would be executed on clients, rather than on server.

This tutorial have more details about client/server stuff: Client/Server relationship and how they communicate | Crayta Developer Area

Now let’s write a function to automatically move our player’s character to the same location, where ship is placed. To do it we are gonna write a function like this:

function Pilot:TrackShip()
    local ship = self.properties.ship
    if ship then
        self.me:SetPosition(ship:GetPosition())
    end
end

Here we are reading properties bag of Pilot script (self.properties) property named ship and in case if our pilot has any ship available, we are setting position of pilot to the same position as ship.

Now we need to make it so that Crayta would execute this function when we need it to.

To do it, let’s add one more function named OnTick.


function Pilot:OnTick(diff)
    self:TrackShip()
end

Crayta knows that OnTick should be executed on every tick.

This tutorial explains about OnTick as well as Schedules (which we are gonna use later here):

Coroutines in Crayta. Using Schedules and OnTick | Crayta Developer Area

Starship script

Now our task is to figure out how to set ship property on Pilot.

For this, let’s open Starship script (use Ctrl+O again).

In this script let’s write function like this:


function Starship:DeployFlight(player)
    local user = player:GetUser()
    user:DespawnPlayerWithEffect(function()
        local pilot = user:SpawnPlayer(GetWorld():FindTemplate("Pilot"), self.me:GetPosition(), Rotation.Zero)
        pilot.Pilot.properties.ship = self.me
        self.properties.pilot = pilot
    end)
end

This is a bit more complicated. We are getting entity of our regular standard player (probably not the Pilot). Then we read user from that player.

The difference between player and user is explained in this tutorial:

The relationship between Player and User | Crayta Developer Area

We use user to DespawnPlayerWithEffect, which would create a nice looking animation of player disappearing and after this has completely happened, the function passed in the parentheses would be called.

That function would spawn a new pilot. For simplicity here I am getting template using FindTemplate call on the World.

This is quick and dirty, but might potentially create problems later when you package stuff and probably also would create unnecessary performance load, however for the sake of this tutorial let’s just go with it.

New pilot is spawned in the same location as ship is and without any particular rotation.

Note use of self.me in that line and next one.

Yes, you gonna have to set this in another Init like this:

function Starship:Init()
    self.me = self:GetEntity()
end

Finally DeployFlight is binding together ship and pilot together.

Line self.properties.pilot = pilot fills pilot property on the ship script, for which we would need to have to configure property accordingly

Starship.Properties = {
    {name = "pilot", type = "entity"},
}

This way ship and pilot know each other.

Deploying flight from trigger

Now let’s try to allow player to interact with the ship in order to trigger DeployFlight function. Interaction is one of standard events that can happen in Crayta, as it is explained in this tutorial:

Basic Tutorial : Events | Crayta Developer Area

To use interaction, I prefer to create Trigger, because it can be bigger than our kitbashed ship and much easier to target with interaction for player.

For this open Starship template and create child of type Trigger

Now that you have trigger1 (or other autogenerated name) you gonna have to configure it interactable and give it a big bigger size

And then add binding to On Interact (press + Button next to “On Interact”, configure Starship entity, same script and DeployFlight for Event)

Now when player interacts with the trigger, interact event would cause DeployFlight to be called.

Note how size of trigger is bigger than kitbashed ship:

Previewing

Start preview and interact with the ship.

I assume you are familiar with how to start preview, but just in case you need a reminder, check starting tutorials:

Crayta Editor: Basic Mode Workflow | Crayta Developer Area

Interact button on keyboard is E and on controller is X.

If you did everything correct, your player would disappear and appear again inside of the ship and won’t be able to go out of it. This is because we are always resetting position of player back to the middle of the ship.

Configure Pilot template

Now while this looks funny, we don’t really need the character model if we are “inside” the ship.

Therefore we are going to configure Pilot template differently from standard Player template (which is why we created second template in the first place).

Go to Pilot template, uncheck Visible and select No Collisions for Collision Preset.

Now the Pilot is basically a fake entity, which we only use in order to manipulate player’s camera.

If you start the preview now and interact with ship again, you might be surprised, by a lot of movement of camera, while pilot tries to fall through the floor (assuming your ship have been above the floor from the beginning).

Add LocalOnTick

This is happening due to the fact that OnTick is called on server and it takes a bit of time to propagate the position reset to clients, but clients process gravity faster than network stuff is happening. Therefore we gonna need to do the same thing we do in OnTick, but now in LocalOnTick.

Open Pilot script and add this:

function Pilot:LocalOnTick(diff)
    self:TrackShip()
end

Configure Pilot camera

Now after this, you will probably still have some flickering, this is due to camera colliding with entities. You need to configure for pilot in Camera Type - Orbit and uncheck Camera Collision.

After this you should be able to look around your spaceship with camera and not have any flickering.

Additional check

We probably do not want our ship to be interacted with anymore for flight deploy with another player (ship needs a captain, but only one at the moment really).

Therefore let’s add one early return at the start of our DeployFlight function

function Starship:DeployFlight(player)
    if self.properties.pilot then
        return
    end

Without this, pressing interact button again while in the ship would cause respawn, which we probably do not need.

Make it fly

Add thrusters logic to Starship

Open Starship script. We are going now to implement a very primitive physics to move our ship based on which thrusters player choose to enable.

Add following function:


function Starship:OnTick(diff)
    local newVelocity = self.me:GetVelocity()
    local acceleration = 500

    if self.thrusters.forward then
        local forward = self.me:GetForward()
        local speed = newVelocity:Length()
        speed = speed + acceleration * diff
        newVelocity = forward * speed
    end

    if self.thrusters.backward then
        newVelocity = newVelocity * 0.9
    end

    if newVelocity:Length() > 2000 then
        newVelocity = newVelocity * 0.99
    end

    self.me:SetVelocity(newVelocity)
end

In this OnTick, we define a velocity of our starship. We start from current velocity, then in case if forward thruster is enabled, we increase speed, absolute value of velocity based on acceleration and time difference.

Acceleration here is simply made as a local variable, you could later move it out to property and configure differently (f.e. for different types of starships).

Variable diff is a time difference between this call of OnTick and previous one. Multiplied, they provide a change we need to make to the absolute value of velocity in order to speed up forward.

In case if backward (slow down) thruster is enabled, we simply reduce velocity by multiplying it on 0.9 . You could choose any value here as long as it is less than 1 and more than 0.

Next part is speed cap. In case if absolute value of velocity is more than 2000 cm per second or 2 m/s, we would like to slow down a bit. This is basic inertia damper to avoid too big speeds.

Finally we set new value of velocity that we have just calculated.

You might have noticed that we reference self.thrusters , which was not set. Let’s set it in Init function like this:


function Starship:Init()
    self.me = self:GetEntity()
    self.thrusters = {}
end

Now apart from initializing self.me, we also setup empty thrusters.

Next part would be changing Pilot script to control those thrusters.

Add thrusters control to Pilot

Open Pilot script and add following methods


function Pilot:OnButtonPressed(name)
    self:OnMovement(name, true)
end

function Pilot:OnButtonReleased(name)
    self:OnMovement(name, false)
end

function Pilot:LocalOnButtonPressed(name)
    self:OnMovement(name, true)
end

function Pilot:LocalOnButtonReleased(name)
    self:OnMovement(name, false)
end

Each of those methods is listening for Crayta event related to buttons, which player can press.

name variable keeps name of button. Pressed/Released is for events of pressing and releasing the button.

Methods with Local are called on client, others on server.

All methods also refer to the same OnMovement method, which is not Crayta specific, I just named it like this.

Let’s add that method as well:


function Pilot:OnMovement(name, on)

    if name == "forward" or name == "backward" or name == "right" or name == "left" then

        self.properties.ship.Starship.thrusters[name] = on

    end

end

Here we are wiring thrusters, which we prepared before in Starship script with the input from player.

Names forward/backward, etc, are standard Crayta designations for buttons (or in case of controller - state of left stick).

List of all available names of buttons is only available on Discord (AFAIK).

Line self.properties.ship.Starship.thrusters[name] = on is very interesting. Here we firstly reference the ship entity, which we have reference to from properties. Then we reference Starship script, which is attached to that entity. And finally we reference ‘internal’ key of that script “thrusters”, exactly the one, which Starship script was referring to as self.thrusters.

Variable on is, as we have seen before, depends on whether button was Pressed or Released. We enable thruster on press and disable it on button release.

Run preview, interact with ship and try forward and backward movement controls.

You should notice that movement is somewhat flickery again. This is because even though we have LocalOnButtonPressed and LocalOnButtonReleased on the client as well as server, Starship on the client actually does not care about that thrusters on client, because we only made it to change velocity in OnTick, which is executed only on server and then changed velocity is replicated to clients.

Let’s try to make it a bit better and make similar calculation on client.

Now you could also notice this error message printed in console


[Client] [Error] Pilot:50: attempt to index field 'thrusters' (a nil value)

stack traceback:

    Pilot:50: in function 'OnMovement'

    Pilot:41: in function <Pilot:40>

This is something that happens on [Client], when local button press/release is processed, thrusters are not even initialized, therefore Lua complains that it cannot “index field ‘thrusters’ (a nil value)”.

First thing then is to make sure that client does have that value setup.

The really easy way to do it is using ClientInit method. Let’s add following into Starship script:


function Starship:ClientInit()

    self.me = self:GetEntity()

    self.thrusters = {}

end

Note that we here initializing self.me as usual and also setting thrusters. self.me is not yet used on client, but thrusters will be.

ClientInit is different from LocalInit is that it is executed on all clients, not only on client related to the user/player in context.

Ship does not have any user or player in context, the references between ship and pilot are artifially created by us and Crayta does not know about them, that is why LocalInit will not work in Starship script, since there is no any client it is attached to. But initializing on all clients is not a big deal and it works for our purposes at the moment.

Running preview again and testing some flying you will see that movement is not very smooth still, but there are no errors in console, which is already better.

Now let’s modify in Pilot script function LocalOnTick we have created earlier like this:


function Pilot:LocalOnTick(diff)

    self:TrackShip()

    self.properties.ship.Starship:OnTick(diff)

end

We still keep TrackShip we had earlier, but not after the position of player is adjusted, we also asking ship to do everything it does on server OnTick event, but on client and only client of pilot controlling that ship. Since client is executing local ticks much faster than server does, the result should be slightly better and less jittery. It might still be jittery sometimes, but already should be good enough.

Add rotation control

Rotation control with mouse already exists in Crayta, because camera is controlled like this. We are going to piggyback on this and make our starship to rotate with the camera.

Firstly let’s write function to use camera rotation, save it into variable and additionally rotate the ship.


function Pilot:CalculateCurrentForward()

    local pitch = self.me.cameraPitch

    local yaw = self.me.cameraYaw

    local rotation = Rotation.New(pitch, yaw, 0)

   

    local newForward = rotation:RotateVector(Vector.New(1, 0, 0))

    self.currentForward = newForward

    self.properties.ship:SetForward(newForward)

end

We are reading pitch and yaw from camera (roll is never changed for camera), create rotation from those values, then use RotateVector on a basis vector (the one, which just points forward) and finally use received vector to point ship in that direction. We also save the value into self.currentForward. We are going to use that a little bit later, but for now let’s add call to this function to our LocalOnTick:


function Pilot:LocalOnTick(diff)

    self:CalculateCurrentForward()

    self:TrackShip()

    self.properties.ship.Starship:OnTick(diff)

end

Running preview you will notice that ship started to rotate together with our camera, but the movement of the ship is still does not care about that. Why? Because we added it on LocalOnTick and server does not know that ship changed direction. Therefore when velocity is calculated in Starship:OnTick - ships GetForward is always still in the same direction. Discrepancy between client and server is later solved very easily - server wins. So let’s give server information about where currently starship points to.

Now we might be tempted to do this in LocalOnTick. Do not do it. It is executed far too often. Instead make it being communicated from the schedule like this:


function Pilot:SetForward(forward)

    self.properties.ship:SetForward(forward)

end

function Pilot:CommunicateRotation()

    self.currentForward = self.me:GetForward()

    self:Schedule(function()

        while self.me:IsValid() do

            Wait(0.3)

            self:SendToServer("SetForward", self.currentForward)

        end

    end)

end

SetForward here is simply receiving vector and forwarding it to ship to set direction. CommunicateRotation function is more interesting, it firstly records current forward direction into the same variable we have seen before self.currentForward - this is just to make sure we have it set for sure even before local ticks happened. Then we create Schedule, which is every 0.3 seconds would send and event to server, which would trigger SetForward function with the vector that was saved (and continuously updated) in self.currentForward.

Final part here is to make sure we call CommunicateRotation at least and at most once on the client, which is in LocalInit of course:


function Pilot:LocalInit()

    self.me = self:GetEntity()

    self:CommunicateRotation()

end

This should already work as a decent enough flying. It is a bit jittery, mainly due to server/client mismatch, when client thinks that more force was applied than server managed to notice and client has to be corrected later.

It should be possible to adjust for this and if I figure how to do it, I will update this tutorial :slight_smile:

Good luck coding and flying in Crayta!

2 Likes