Skip to content
GameGenresEspañol
Action

Collectathon

You explore wide worlds hunting for huge numbers of items, and gathering them isn't optional: what you collect unlocks new levels and brings you closer to the end.

Also known as: collect-a-thon, collect-a-thon platformer

This is a basic entry: it has the definition and the classification. Full entries add history, games and a demo.

Our demo

Controls. Arrows or WASD to move (relative to the camera), space to jump and drag with the mouse to turn the camera. On mobile, the on-screen buttons and dragging with your finger.

Open full screen

A small 3D world full of gems. Collecting enough of them opens the gate that leads to the star: what you collect isn't decoration, it's what lets you move on. It is made with threeplay, a 3D engine in the spirit of Kaplay.

Made with threeplay, in about 155 lines of JavaScript.

How it's made

  1. Setup and controls

    The game is created at 640×360 and virtual buttons are defined; the on-screen mobile buttons press those same buttons. Gravity, speed and jump strength are the numbers that most change the feel, and the REQUIRED constant decides how much you must explore before moving on.

    const k = threeplay({
      width: 640,
      height: 360,
      letterbox: true,
      background: "#8ecae6",
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        jump: { keyboard: ["space"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "jump", label: "⤒", side: "right" },
    ]);
    setGravity(30);
    const SPEED = 6;
    const JUMP = 11;
    const REQUIRED = 18; // gems needed to open the gate
  2. The world

    The world is a list of boxes with a position and a size. Each one has area() and body({ isStatic: true }) so the character can stand on it. A wall cuts across the map and leaves a gap that the gate closes.

    // [x, y, z, width, height, depth]: y is the center of the box.
    const PLATFORMS = [
      [10, 0.75, 2, 3, 1.5, 3],
      [13, 1.75, -2, 3, 0.5, 3],
      [10, 2.75, -6, 3, 0.5, 3],
      [-7, 0.5, 4, 3, 1, 3],
      [-10, 1.5, 1, 3, 1, 3],
      [-13, 2.5, -2, 4, 1, 4],
      // Behind the gate
      [0, 1, -16, 3, 0.5, 3],
      [3, 2.2, -18.5, 2.5, 0.5, 2.5],
      [0, 3.4, -21, 2.5, 0.5, 2.5],
    ];
    
    function buildWorld() {
      add([box(44, 1, 44), pos(0, -0.5, 0), color("#90be6d"), area(), body({ isStatic: true })]);
      for (const [x, y, z, w, h, d] of PLATFORMS) {
        add([box(w, h, d), pos(x, y, z), color("#bc6c25"), area(), body({ isStatic: true })]);
      }
      // A wall across the map, with a gap closed by the gate.
      for (const x of [-11.5, 11.5]) {
        add([box(20, 4, 1), pos(x, 2, -12), color("#6c757d"), area(), body({ isStatic: true })]);
      }
    }
  3. Gems in lines and rings

    Gems are placed in rows, rings and on top of every platform. That layout is design: a row of gems shows the way without words, and a gem on a high platform invites you to try the jump. They are three.js octahedrons that spin through a custom component, spin, which just changes rot.y every frame.

    // Gems follow lines and rings: trails tell players where to go without words.
    function gemSpots() {
      const spots = [];
      for (let z = 6; z >= -6; z -= 2) spots.push(vec3(0, 0.8, z)); // a trail north
      for (let i = 0; i < 8; i++) {
        const a = (i / 8) * Math.PI * 2;
        spots.push(vec3(6 + Math.cos(a) * 3, 0.8, 8 + Math.sin(a) * 3)); // a ring
      }
      for (const [x, y, z, , h] of PLATFORMS) spots.push(vec3(x, y + h / 2 + 0.8, z)); // one per platform
      return spots;
    }
    
    function spin(speed) {
      return {
        id: "spin",
        require: ["rotate"],
        update() {
          this.rot.y += speed * dt();
        },
      };
    }
    
    function addGem(p) {
      add([mesh(new THREE.OctahedronGeometry(0.3)), pos(p), rotate(), spin(120), color("#4cc9f0"), area(), "gem"]);
    }
    
    // The big prize: a five-pointed star extruded from a 2D outline.
    function addStar(p) {
      const outline = new THREE.Shape();
      for (let i = 0; i < 10; i++) {
        const r = i % 2 === 0 ? 0.7 : 0.3;
        const a = (i / 10) * Math.PI * 2 + Math.PI / 2;
        outline[i === 0 ? "moveTo" : "lineTo"](Math.cos(a) * r, Math.sin(a) * r);
      }
      const geometry = new THREE.ExtrudeGeometry(outline, { depth: 0.2, bevelEnabled: false });
      geometry.center();
      add([mesh(geometry), pos(p), rotate(), spin(90), color("gold"), area(), "star"]);
    }
  4. The character and the camera

    The player is a capsule with character(), a controller that slides along walls and climbs small steps. inputDir() turns the keys into a direction relative to the camera, which is how almost every third-person 3D game is controlled: «forward» is where the camera looks. thirdPerson() lets you turn the camera by dragging.

    const player = add([capsule(0.4, 1.6), pos(0, 1, 9), rotate(), color("#f77f00"), area(), character(), "player"]);
    player.add([box(0.3, 0.2, 0.3), pos(0, 0.4, -0.4), color("#003049")]); // shows where it faces
    thirdPerson(player, { distance: 8, height: 1.2 });
    
    onUpdate(() => {
      const dir = inputDir(); // relative to the camera, on the ground plane
      player.move(dir.clone().multiplyScalar(SPEED));
      if (dir.lengthSq() > 0) player.lookAt(player.worldPos().add(dir));
      if (player.pos.y < -10) player.teleport(vec3(0, 1, 9)); // fell off the world
    });
    onButtonPress("jump", () => {
      if (player.isGrounded()) player.jump(JUMP);
    });
  5. The gate that opens with gems

    This is the heart of the genre. The gate is a kinematic body, so it blocks the way but can be moved from code. A floating sign counts the gems still missing and, when you reach REQUIRED, a tween sinks it into the ground.

    // Progress is gated by what you collect: the core loop of the genre.
    const gate = add([box(3, 4, 0.6), pos(0, 2, -12), color("#e63946"), area(), body({ kinematic: true })]);
    const sign = gate.add([text("", { size: 18, font: FONT }), pos(0, 2.6, 0), anchor("center")]);
    let gateOpen = false;
    
    function openGate() {
      gateOpen = true;
      sign.hidden = true;
      beep({ freq: 220, slideTo: 660, duration: 0.5, type: "triangle" });
      tween(gate.pos.y, -2.2, 1.5, (y) => (gate.pos.y = y), easings.easeInOutQuad);
    }
  6. The counter

    Texts with fixed() live on the screen, not in the world. The counter shows how many gems you have against the ones you need, which is the information that guides everything you do.

    let gems = 0;
    const hud = add([text("", { size: 18, font: FONT }), pos(14, 10), fixed()]);
    function updateHud() {
      hud.text = `◆ ${gems} / ${REQUIRED}`;
      sign.text = gateOpen ? "" : `◆ ${Math.max(0, REQUIRED - gems)}`;
    }
    updateHud();
    add([
      text(t({ es: "Arrastra para girar la cámara", en: "Drag to turn the camera" }), { size: 12, font: FONT }),
      pos(626, 10),
      anchor("topright"),
      fixed(),
    ]);
  7. Collecting

    Gems have area() without body(), so they are sensors that don't stop the character. onCollide("gem") destroys them, adds one and raises the pitch of the beep so that collecting several in a row sounds like a reward. The star ends the game.

    player.onCollide("gem", (gem) => {
      destroy(gem);
      gems++;
      beep({ freq: 880 + (gems % 5) * 110, duration: 0.08 });
      updateHud();
      if (gems >= REQUIRED && !gateOpen) openGate();
    });
    player.onCollide("star", (star) => {
      destroy(star);
      beep({ freq: 523, slideTo: 1046, duration: 0.6, type: "triangle" });
      wait(0.6, () => go("end", gems));
    });
  8. Scenes

    Three scenes (title, game and end) and go() to move between them. The title screen reuses the world with a camera that orbits on its own.

    scene("title", () => {
      add([text("Collectathon", { size: 40, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
      add([
        text(t({ es: "Junta gemas para abrir la puerta y alcanza la estrella", en: "Collect gems to open the gate and reach the star" }), { size: 16, font: FONT }),
        pos(320, 190),
        anchor("center"),
        fixed(),
      ]);
      add([text(t({ es: "Espacio para empezar", en: "Space to start" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
      orbitCamera({ distance: 26, autoRotate: 10, interactive: false });
      buildWorld();
      onButtonPress("jump", () => go("game"));
    });
    
    scene("end", (gems) => {
      add([text(t({ es: "¡Estrella conseguida!", en: "Star collected!" }), { size: 36, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
      add([
        text(t({ es: `Gemas: ${gems}. En el juego real, la estrella abriría un mundo nuevo.`, en: `Gems: ${gems}. In a real game, the star would open a new world.` }), { size: 15, font: FONT }),
        pos(320, 190),
        anchor("center"),
        fixed(),
      ]);
      add([text(t({ es: "Espacio para volver a jugar", en: "Space to play again" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
      onButtonPress("jump", () => go("game"));
    });
    
    go("title");

What we simplified

  • A single small world with one lock (the gate) and one star; a real collectathon chains many worlds that open with what you collect.
  • The character is a capsule with no animations; the sound is synthesized beeps.
  • There are no different kinds of collectibles or hidden secrets.

To modify it

  • Add a second gate that requires every gem, and hide one gem behind a wall.
  • Make the gems turn towards the player when it's close and fly to it.
  • Add a double jump that only unlocks after picking up a special item.
Full code
import threeplay from "../../vendor/threeplay/threeplay.mjs";
import { FONT, t, touchControls } from "../common.js";

// #region setup
const k = threeplay({
  width: 640,
  height: 360,
  letterbox: true,
  background: "#8ecae6",
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    jump: { keyboard: ["space"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "jump", label: "⤒", side: "right" },
]);
setGravity(30);
const SPEED = 6;
const JUMP = 11;
const REQUIRED = 18; // gems needed to open the gate
// #endregion

// #region world
// [x, y, z, width, height, depth]: y is the center of the box.
const PLATFORMS = [
  [10, 0.75, 2, 3, 1.5, 3],
  [13, 1.75, -2, 3, 0.5, 3],
  [10, 2.75, -6, 3, 0.5, 3],
  [-7, 0.5, 4, 3, 1, 3],
  [-10, 1.5, 1, 3, 1, 3],
  [-13, 2.5, -2, 4, 1, 4],
  // Behind the gate
  [0, 1, -16, 3, 0.5, 3],
  [3, 2.2, -18.5, 2.5, 0.5, 2.5],
  [0, 3.4, -21, 2.5, 0.5, 2.5],
];

function buildWorld() {
  add([box(44, 1, 44), pos(0, -0.5, 0), color("#90be6d"), area(), body({ isStatic: true })]);
  for (const [x, y, z, w, h, d] of PLATFORMS) {
    add([box(w, h, d), pos(x, y, z), color("#bc6c25"), area(), body({ isStatic: true })]);
  }
  // A wall across the map, with a gap closed by the gate.
  for (const x of [-11.5, 11.5]) {
    add([box(20, 4, 1), pos(x, 2, -12), color("#6c757d"), area(), body({ isStatic: true })]);
  }
}
// #endregion

// #region collectibles
// Gems follow lines and rings: trails tell players where to go without words.
function gemSpots() {
  const spots = [];
  for (let z = 6; z >= -6; z -= 2) spots.push(vec3(0, 0.8, z)); // a trail north
  for (let i = 0; i < 8; i++) {
    const a = (i / 8) * Math.PI * 2;
    spots.push(vec3(6 + Math.cos(a) * 3, 0.8, 8 + Math.sin(a) * 3)); // a ring
  }
  for (const [x, y, z, , h] of PLATFORMS) spots.push(vec3(x, y + h / 2 + 0.8, z)); // one per platform
  return spots;
}

function spin(speed) {
  return {
    id: "spin",
    require: ["rotate"],
    update() {
      this.rot.y += speed * dt();
    },
  };
}

function addGem(p) {
  add([mesh(new THREE.OctahedronGeometry(0.3)), pos(p), rotate(), spin(120), color("#4cc9f0"), area(), "gem"]);
}

// The big prize: a five-pointed star extruded from a 2D outline.
function addStar(p) {
  const outline = new THREE.Shape();
  for (let i = 0; i < 10; i++) {
    const r = i % 2 === 0 ? 0.7 : 0.3;
    const a = (i / 10) * Math.PI * 2 + Math.PI / 2;
    outline[i === 0 ? "moveTo" : "lineTo"](Math.cos(a) * r, Math.sin(a) * r);
  }
  const geometry = new THREE.ExtrudeGeometry(outline, { depth: 0.2, bevelEnabled: false });
  geometry.center();
  add([mesh(geometry), pos(p), rotate(), spin(90), color("gold"), area(), "star"]);
}
// #endregion

scene("game", () => {
  buildWorld();
  for (const p of gemSpots()) addGem(p);
  addStar(vec3(0, 4.6, -21));

  // #region player
  const player = add([capsule(0.4, 1.6), pos(0, 1, 9), rotate(), color("#f77f00"), area(), character(), "player"]);
  player.add([box(0.3, 0.2, 0.3), pos(0, 0.4, -0.4), color("#003049")]); // shows where it faces
  thirdPerson(player, { distance: 8, height: 1.2 });

  onUpdate(() => {
    const dir = inputDir(); // relative to the camera, on the ground plane
    player.move(dir.clone().multiplyScalar(SPEED));
    if (dir.lengthSq() > 0) player.lookAt(player.worldPos().add(dir));
    if (player.pos.y < -10) player.teleport(vec3(0, 1, 9)); // fell off the world
  });
  onButtonPress("jump", () => {
    if (player.isGrounded()) player.jump(JUMP);
  });
  // #endregion

  // #region gate
  // Progress is gated by what you collect: the core loop of the genre.
  const gate = add([box(3, 4, 0.6), pos(0, 2, -12), color("#e63946"), area(), body({ kinematic: true })]);
  const sign = gate.add([text("", { size: 18, font: FONT }), pos(0, 2.6, 0), anchor("center")]);
  let gateOpen = false;

  function openGate() {
    gateOpen = true;
    sign.hidden = true;
    beep({ freq: 220, slideTo: 660, duration: 0.5, type: "triangle" });
    tween(gate.pos.y, -2.2, 1.5, (y) => (gate.pos.y = y), easings.easeInOutQuad);
  }
  // #endregion

  // #region hud
  let gems = 0;
  const hud = add([text("", { size: 18, font: FONT }), pos(14, 10), fixed()]);
  function updateHud() {
    hud.text = `◆ ${gems} / ${REQUIRED}`;
    sign.text = gateOpen ? "" : `◆ ${Math.max(0, REQUIRED - gems)}`;
  }
  updateHud();
  add([
    text(t({ es: "Arrastra para girar la cámara", en: "Drag to turn the camera" }), { size: 12, font: FONT }),
    pos(626, 10),
    anchor("topright"),
    fixed(),
  ]);
  // #endregion

  // #region collect
  player.onCollide("gem", (gem) => {
    destroy(gem);
    gems++;
    beep({ freq: 880 + (gems % 5) * 110, duration: 0.08 });
    updateHud();
    if (gems >= REQUIRED && !gateOpen) openGate();
  });
  player.onCollide("star", (star) => {
    destroy(star);
    beep({ freq: 523, slideTo: 1046, duration: 0.6, type: "triangle" });
    wait(0.6, () => go("end", gems));
  });
  // #endregion
});

// #region scenes
scene("title", () => {
  add([text("Collectathon", { size: 40, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
  add([
    text(t({ es: "Junta gemas para abrir la puerta y alcanza la estrella", en: "Collect gems to open the gate and reach the star" }), { size: 16, font: FONT }),
    pos(320, 190),
    anchor("center"),
    fixed(),
  ]);
  add([text(t({ es: "Espacio para empezar", en: "Space to start" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
  orbitCamera({ distance: 26, autoRotate: 10, interactive: false });
  buildWorld();
  onButtonPress("jump", () => go("game"));
});

scene("end", (gems) => {
  add([text(t({ es: "¡Estrella conseguida!", en: "Star collected!" }), { size: 36, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
  add([
    text(t({ es: `Gemas: ${gems}. En el juego real, la estrella abriría un mundo nuevo.`, en: `Gems: ${gems}. In a real game, the star would open a new world.` }), { size: 15, font: FONT }),
    pos(320, 190),
    anchor("center"),
    fixed(),
  ]);
  add([text(t({ es: "Espacio para volver a jugar", en: "Space to play again" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
  onButtonPress("jump", () => go("game"));
});

go("title");
// #endregion

History

  1. 1996
    OriginSuper Mario 64

    Super Mario 64 gave each level objectives and made the stars you earned unlock new worlds; Banjo-Kazooie, Spyro the Dragon and Donkey Kong 64 borrowed the format, and the genre took shape.

    Platformer (Wikipedia)

Reference games

1996

Super Mario 64

Nintendo. Nintendo 64

Took platforming into open 3D worlds with analogue control and a free camera, shifting the goal from reaching the end to exploring.