Skip to content

First-Person Controller ​

Write a first-person character controller - WASD movement, mouse-look, and jump, with real capsule collision against the level - as an ordinary script you attach to a player object.

Awaken does not bake a controller into the engine. Instead the engine exposes two generic primitives every game needs - mouse-look input (pointer lock + per-frame mouse deltas) and a capsule move-and-slide helper - and you write the controller logic on top of them as a normal script. This tutorial builds the standard rig on those primitives - a capsule player, a camera the script drives, and a per-frame update that looks, moves, and collides - all using the ScriptApi surface (api.moveAndSlide, api.input) the engine provides for it.

If you would rather not assemble the rig by hand, the First-person walk template on the Welcome page places a ready-made First Person Player prefab (from the Store's free package), and the same controller ships as starter content: prefabs/First Person Player.awakenprefab with scripts/game/First Person Controller.awakenscript. This tutorial builds what that prefab is made of.

What you will build ​

A player you can walk around a level in first person. The capsule collides with walls and floors (slopes and small steps are handled by the character controller), the mouse looks around after you click to lock the pointer, Space jumps, and the camera follows at eye height. The controller is a single script asset with tunable Inspector params, so you can reuse it in any project.

The rig ​

  • Player - a GameObject with a capsule Collider at human size (radius ≈ 0.35 m, height ≈ 1.8 m) and the controller Script attached. The capsule is what collides; there is no RigidBody - the character controller is kinematic, so you move it and physics resolves the slide.
  • Camera - an ordinary Camera object that the script keeps at the player's eye height and pitches up/down. You hand it to the script as a camera reference param, so the controller stays generic.

Before you start ​

Steps ​

  1. Build the player. Start from the Empty scene template, then + → Create → Capsule in the Hierarchy and rename it Player. Lift it so it stands on the ground (Y = 1). Press + Add Component…, add a Collider, set its shape picker to capsule and set radius 0.35 and height 1.8 (or press Fit). See Colliders.

    A Collider in the Inspector: the shape picker, Fit, and the size fields.

  2. Pick the camera. The scene's Main Camera will do. Keep it a root object, not a child of the player: the script writes its world-space pose every frame.

  3. Declare the controller's params. In the Code panel, start a ScriptBehavior whose public fields become Inspector controls (see Inspector Parameters):

    ts
    export default class implements ScriptBehavior {
      camera: Camera;               // typed with the Camera component, so the picker lists only cameras
      moveSpeed = 5;                // m/s
      mouseSensitivity = 0.0025;
      jumpHeight = 1.2;             // m
      eyeHeight = 0.7;              // m above the capsule's centre: about eye height for a 1.8 m capsule
      gravity = 20;                 // m/s²
    
      s = { yaw: 0, pitch: 0, vy: 0 };
      // onUpdate below…
    }
  4. Look. In onUpdate, accumulate yaw -= api.input.mouseDX * this.mouseSensitivity and pitch -= api.input.mouseDY * this.mouseSensitivity (clamp pitch to ≈ ±1.55 rad). Rotate the player body by yaw about world-Y; apply pitch to the camera. Mouse deltas are only non-zero while the pointer is locked - clicking the Game view locks it (api.input.pointerLocked). See Input.

  5. Move. Build a horizontal input vector from WASD in the player's yaw frame and scale by moveSpeed.

  6. Gravity & jump. Integrate this.vy -= this.gravity * dt; if grounded and Space is down, set this.vy = Math.sqrt(2 * this.gravity * this.jumpHeight); zero vy when grounded and falling.

  7. Collide. Move the capsule with the engine primitive and read back whether you landed:

    ts
    const { grounded } = api.moveAndSlide(move.x * dt, this.vy * dt, move.z * dt);

    The capsule is promoted to a kinematic character body on the first call; walls block, slopes and small steps are handled. See Character Controller.

  8. Follow with the camera. Place the camera at playerPos + (0, eyeHeight, 0) with the composed yaw·pitch rotation, writing its Transform through api.world (the camera param holds the camera's entity).

  9. Attach and assign. Add the Script component to Player, pick your script, then set the camera param to Main Camera in the Inspector. With no camera assigned, or the script on an object without a Collider, the script says so in the Console at Play.

  10. Play. Press ▶ Play, click the Game view to lock the pointer, and walk the level. Esc releases the pointer. Press ■ Stop to restore the scene.

Play in first person: walking through the yard towards the ramp and crates.

The First-person walk template's player in the Inspector: its capsule Collider, then the First Person Controller script with camera set to Main Camera above moveSpeed, mouseSensitivity, jumpHeight, eyeHeight, gravity, stepHeight, slopeLimit, skinWidth and nearClip.

The full script ​

This is the controller the starter content ships (starter-content/scripts/game/First Person Controller.awakenscript). Beyond the steps above it passes stepHeight, slopeLimit and skinWidth to moveAndSlide, and forces a small camera near-clip so the view does not render through walls the capsule stops you just short of.

ts
// First-person character controller. Attach to a capsule-collider player; assign your scene camera to `camera`.
// Assign a ROOT camera (not parented under the player) to the camera param -- the script writes a world-space pose.
export default class implements ScriptBehavior {
  camera: Camera;              // the camera to drive - annotating the Camera component filters the picker to cameras
  moveSpeed = 5;
  mouseSensitivity = 0.0025;
  jumpHeight = 1.2;
  eyeHeight = 0.7; // camera offset above the player origin (capsule centre) -- ~eye height for a 1.8m capsule
  gravity = 20;
  stepHeight = 0.3;  // max stair/curb height auto-climbed without jumping (m). Raise for taller steps.
  slopeLimit = 52;   // steepest slope you can walk up (degrees)
  skinWidth = 0.08;  // gap kept between the capsule and walls (m). Larger stops the camera clipping into walls.
  nearClip = 0.05;   // camera near-clip (m). Small so the view doesn't render THROUGH nearby walls. Imported cameras
                     // often carry a large near (Unity default 0.3) that clips through walls - this overrides it.
  s = { yaw: 0, pitch: 0, vy: 0 };


  onStart(api: ScriptApi) {
    // The two silent killers of a character controller, said OUT LOUD. moveAndSlide moves THE ENTITY THIS SCRIPT
    // IS ON - put the script on a camera (or anything without a capsule Collider) and movement no-ops with no
    // error while mouse look still works, which reads as "the game is broken".
    const Col = api.world.registry.get("Collider");
    if (!Col || !api.world.get(api.entity, Col)) {
      api.log("[controller] This entity has no Collider - moveAndSlide cannot move it. Put this script on the player capsule (the First/Third Person Player prefab), not on the camera.");
    }
    if (!this.camera) {
      api.log("[controller] No camera assigned - the view will not follow. Assign a ROOT scene camera to the script's camera parameter.");
    }
  }

  onUpdate(api: ScriptApi, dt: number) {
    // Look
    if (api.input.pointerLocked) {
      this.s.yaw   -= api.input.mouseDX * this.mouseSensitivity;
      this.s.pitch -= api.input.mouseDY * this.mouseSensitivity;
      this.s.pitch = Math.max(-1.55, Math.min(1.55, this.s.pitch));
    }
    const sy = Math.sin(this.s.yaw), cy = Math.cos(this.s.yaw);
    // Rotate the player BODY by yaw (world Y)
    api.setField("Transform", "rotation", { x: 0, y: Math.sin(this.s.yaw / 2), z: 0, w: Math.cos(this.s.yaw / 2) });
    // WASD in the yaw frame (forward = -Z)
    let fx = 0, fz = 0;
    if (api.input.isDown("w")) fz -= 1;
    if (api.input.isDown("s")) fz += 1;
    if (api.input.isDown("a")) fx -= 1;
    if (api.input.isDown("d")) fx += 1;
    const len = Math.hypot(fx, fz) || 1;
    // world move = rotate local (fx,fz) by the SAME +yaw about +Y the camera/body use (x'=x·cos+z·sin,
    // z'=-x·sin+z·cos) - so WASD always tracks where you're looking, at any yaw.
    const wx = (fx * cy + fz * sy) / len * this.moveSpeed;
    const wz = (fz * cy - fx * sy) / len * this.moveSpeed;
    // Gravity + jump
    this.s.vy -= this.gravity * dt;
    // Move + collide
    const moved = (fx || fz) ? 1 : 0;
    const r = api.moveAndSlide((moved ? wx : 0) * dt, this.s.vy * dt, (moved ? wz : 0) * dt, { stepHeight: this.stepHeight, slopeLimit: this.slopeLimit, skinWidth: this.skinWidth });
    if (r.grounded && this.s.vy < 0) this.s.vy = 0;
    if (r.grounded && api.input.isDown(" ")) this.s.vy = Math.sqrt(2 * this.gravity * this.jumpHeight);
    // Drive the camera to eye height with yaw + pitch. Guard camera !== self: if the script is (mis)placed on
    // the camera itself with camera pointing at it, driving "self + eyeHeight" each frame flies it into the sky.
    if (this.camera && this.camera !== api.entity) {
      const p = api.getPosition();
      const cyq = { x: 0, y: Math.sin(this.s.yaw / 2), z: 0, w: Math.cos(this.s.yaw / 2) };
      const pxq = { x: Math.sin(this.s.pitch / 2), y: 0, z: 0, w: Math.cos(this.s.pitch / 2) };
      // camera rotation = yaw * pitch
      const rot = {
        x: cyq.w*pxq.x + cyq.x*pxq.w + cyq.y*pxq.z - cyq.z*pxq.y,
        y: cyq.w*pxq.y - cyq.x*pxq.z + cyq.y*pxq.w + cyq.z*pxq.x,
        z: cyq.w*pxq.z + cyq.x*pxq.y - cyq.y*pxq.x + cyq.z*pxq.w,
        w: cyq.w*pxq.w - cyq.x*pxq.x - cyq.y*pxq.y - cyq.z*pxq.z,
      };
      const T = api.world.registry.get("Transform");
      if (T) {
        const t = api.world.get(this.camera, T) as any;
        if (t) { t.position = { x: p.x, y: p.y + this.eyeHeight, z: p.z }; t.rotation = rot; api.world.markMoved(this.camera); }
      }
      // Force a small near-clip so the camera doesn't render THROUGH walls it's close to (an imported camera may
      // carry a large near, e.g. Unity's 0.3, which clips into geometry the capsule holds you just short of).
      const Cam = api.world.registry.get("Camera");
      if (Cam) { const c = api.world.get(this.camera, Cam) as any; if (c && c.near !== this.nearClip) c.near = this.nearClip; }
    }
  }
}

Where to go next ​

See also ​

Awaken: browser-native WebGPU game engine.