<div class="wrapper">
  <small class="touchscreen-message">
    It seems that you are using a touchscreen.
    <br />
    This demo is not optimized for it, please try it on a computer.
  </small>
  
  <div class="intro">Move around using arrows and space.</div>

  <div class="game">
    <div class="player"></div>
    <div class="trail"></div>
  </div>
  
  <pre class="status"></pre>

  <!-- <a class="link" href="./index.html">Read the blog post ↗</a> -->
</div>
$light-blue: rgb(88, 170, 239);
$white: #eef3f5;
$gray: #555;

*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue",
    Helvetica, Inter, Arial, "Noto Sans", sans-serif;
  line-height: 1.5;
  overflow-x: hidden;
  background: #18181b;
  color: $white;
}

.link  {
  color: #6290ff;
  border-bottom: 1px solid transparent;
  transition: color 250ms, border 250ms;
  margin-top: 50px;
  display: block;

  &:hover {
    color: $white;
  }
}

.wrapper {
  padding: 30px;
}

.status {
  display: flex;
  margin-top: 20px;
  gap: 10px;
}

.status div {
  flex-basis: 110px;
}

.status span {
  color: #a783f7;
}

.jump-status {
  height: 15px;
  max-width: 15px;
  flex-shrink: 0;
  border-radius: 50%;
  margin-top: 5px;
  display: inline-block;
}

.jump-status--ready {
  background-color: lime;
  vertical-align: baseline;
}

.jump-status--not-ready {
  background-color: red;
}

.game {
  height: 120px;
  position: relative;
}

.player {
  position: absolute;
  width: 20px;
  height: 30px;
  background: #3171f6;
  // move our blocky player to the coordinate's system origin
  bottom: 0;
  left: -10px;
}

.trail-point {
  position: absolute;
  width: 2px;
  height: 2px;
  bottom: 0;
  left: -1px;
  border-radius: 2px;
}

.intro {
  color: #888;
  position: absolute;
}

.touchscreen-message {
  margin-bottom: 20px;
  color: #f46d9d;
  display: none;
}

@media (max-width: 400px) {
  .touchscreen-message br {
    display: none;
  }
}

@media (hover: none) {
  .touchscreen-message {
    display: block;
  }
}
View Compiled
// ----- Types

export type Vector = {
  x: number;
  y: number;
};

export type Trail = Array<{
  color: string;
  position: Vector;
}>;

// ----- Constants

const FRAME_DURATION: number = 1000 / 60;

// ----- Game state

// Game speed
const speed: number = 1;

// Movement
const accelerationGround: number = 1 * speed;
const decelerationGround: number = 2 * speed;
const maxSpeed: number = 8 * speed;

const decelerationAir: number = 0.5 * speed;

// Jumping
const initialJumpVelocity: number = 8 * speed;
const jumpDeceleration: number = 0.4 * speed;
const jumpFall: number = 1 * speed;
const jumpAllowThreshold: number = 30;

const velocity: Vector = {
  x: 0,
  y: 0
};

const position: Vector = {
  x: 0,
  y: 0
};

let isOnGround: boolean = true;
let isJumpReady: boolean = true;

const trailMaxLength: number = 200;
const trail: Trail = [];
let trailColor: string;

// Render
const statusElement = document.querySelector(".status") as HTMLPreElement;
const playerElement = document.querySelector(".player") as HTMLDivElement;
const trailElement = document.querySelector(".trail") as HTMLDivElement;
const jumpElement = document.querySelector(".jump") as HTMLDivElement;
const introElement = document.querySelector(".intro") as HTMLDivElement;

// Add some color to stringified objects
function replacer(key, value) {
  return typeof value === "number" ? `<span>${value.toFixed(2)}</span>` : value;
}

function prettyStringify(object) {
  return JSON.stringify(object, replacer, 2).replace(/"/g, "");
}

function render() {
  // status
  statusElement.innerHTML = `<div>position: ${prettyStringify(position)}</div>`;
  statusElement.innerHTML += `<div>velocity: ${prettyStringify(velocity)}</div>`;
  statusElement.innerHTML += isJumpReady
    ? '<div class="jump-status jump-status--ready" />'
    : '<div class="jump-status jump-status--not-ready" />';

  // player
  playerElement.style.transform = `translate(${
    position.x
  }px, ${-position.y}px)`;
}

function renderTrail() {
  let trailHTML: string = "";

  for (let i = 0; i < trail.length; i++) {
    const point = trail[i];
    const { x, y } = point.position;

    trailHTML += `<div 
      class="trail-point" 
      style="
        background: ${point.color};
        transform: translate(${x}px, ${-y}px);
      "></div>`;
  }

  trailElement.innerHTML = trailHTML;
}

// ----- Keyboard input
const activeKeys: Record<string, boolean> = {};

const keys = {
  SPACE: " ",
  LEFT: "ArrowLeft",
  RIGHT: "ArrowRight",
  UP: "ArrowUp",
  DOWN: "ArrowDown"
};

window.addEventListener("keydown", (e) => {
  activeKeys[e.key] = true;
  
  // Remove intro text
  introElement.innerHTML = '';
  
  // Prevent page scrolling on space press
  if (e.key === ' ') {
    e.preventDefault();
  }
});

window.addEventListener("keyup", (e) => {
  delete activeKeys[e.key];
});

// ----- Update
function updateHorizontalMovement(delta: number) {
  const isLeftPressed = activeKeys[keys.LEFT];
  const isRightPressed = activeKeys[keys.RIGHT];

  const isExclusivelyLeft = isLeftPressed && !isRightPressed;
  const isExclusivelyRight = isRightPressed && !isLeftPressed;

  const isMovingRight = velocity.x > 0;
  const isMovingLeft = velocity.x < 0;

  const deceleration = isOnGround ? decelerationGround : decelerationAir;

  if (isExclusivelyLeft) {
    trailColor = "lime";

    // Only left arrow is pressed
    if (isMovingRight) {
      // Slow down if player is already moving right
      velocity.x -= deceleration * delta;
    } else {
      // If not, accelerate to the left
      velocity.x -= accelerationGround * delta;
    }
  } else if (isExclusivelyRight) {
    trailColor = "lime";

    // Only right arrow is pressed
    if (isMovingLeft) {
      // Slow down if player is already moving left
      velocity.x += deceleration * delta;
    } else {
      // If not, accelerate to the right
      velocity.x += accelerationGround * delta;
    }
  } else {
    trailColor = "red";

    // Either both or no horizontal arrows are pressed
    // Decelerate to the stop

    if (isMovingRight) {
      // Player is moving right, decelerate
      velocity.x -= deceleration * delta;

      // When velocity starts going in the opposite direction, stop the player
      if (velocity.x < 0) {
        velocity.x = 0;
      }
    } else if (isMovingLeft) {
      // Player is moving left, decelerate
      velocity.x += deceleration * delta;

      // When velocity starts going in the opposite direction, stop the player
      if (velocity.x > 0) {
        velocity.x = 0;
      }
    }
  }

  // Cap at maximum speed
  if (velocity.x > maxSpeed) {
    trailColor = "silver";
    velocity.x = maxSpeed;
  } else if (velocity.x < -maxSpeed) {
    trailColor = "silver";
    velocity.x = -maxSpeed;
  }

  // Update player's position using new velocity value
  position.x += velocity.x * delta;
}

function updateVerticalMovement(delta: number) {
  const isJumpPressed: boolean = activeKeys[keys.SPACE];
  isOnGround = position.y === 0;

  if (isOnGround) {
    if (isJumpPressed && isJumpReady) {
      velocity.y = initialJumpVelocity;
      trailColor = "blue";
      isJumpReady = false;
    }
  } else {
    if (isJumpPressed && velocity.y > 0) {
      trailColor = "blue";
      velocity.y -= jumpDeceleration * delta;
    } else {
      velocity.y -= jumpFall * delta;
      trailColor = "purple";
    }
  }

  // Update position
  position.y += velocity.y * delta;

  // Prevent player going into the ground
  if (position.y < 0) {
    position.y = 0;
    velocity.y = 0;
  }

  if (!isJumpPressed && position.y < jumpAllowThreshold) {
    // allow jumping again
    isJumpReady = true;
  }
}

function updateTrail() {
  const last = trail[trail.length - 1];

  const hasMoved =
    position.x !== last?.position.x || position.y !== last?.position.y;

  if (hasMoved) {
    trail.push({
      color: trailColor,
      position: {
        ...position
      }
    });

    if (trail.length > trailMaxLength) {
      trail.shift();
    }

    // For performance, trail is only rendered when it is changed
    renderTrail();
  }
}

// ----- Game loop
let lastUpdate: number = performance.now();

function gameLoop() {
  const now = performance.now();
  const delta = (now - lastUpdate) / FRAME_DURATION;

  // Update game state
  updateHorizontalMovement(delta);
  updateVerticalMovement(delta);
  updateTrail();

  // Render
  render();

  // Update time
  lastUpdate = now;

  // Next frame
  requestAnimationFrame(gameLoop);
  // setTimeout(gameLoop, 30);
}

gameLoop();
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.