Recreating the Flash Game “Manufactoria” with HTML, CSS, and JavaScript

This article details how to recreate the Flash programming game 'Manufactoria' as a web-based puzzle by implementing its UI with HTML and CSS and its logic with JavaScript, covering component definitions, grid layout, robot movement, state handling, level loading, and local storage persistence.

ByteFE
ByteFE
ByteFE
Recreating the Flash Game “Manufactoria” with HTML, CSS, and JavaScript

The author revisits the classic Flash programming puzzle "Manufactoria" and rebuilds it as a pure web application, allowing users to place components on a grid, run a robot, and solve over twenty levels without any Flash dependency.

Manufactoria uses only two basic component types—conveyors and comparators—each with several visual variants. Conveyors add colored balls to the sequence, while comparators route the robot based on the color of the sequence head.

The UI is constructed from a minimal HTML skeleton. The main panel contains a button strip, a grid container, and informational sections:

<div>🤖🔴🟡🔵🟢🚩 <span>第 <select id="levelPicker"><option>1</option></select> 关</span></div>
<div id="main">
  <div id="panel">
    <div class="buttons">
      <div class="pass green" title="绿色通道:🤖通过时将🟢添加到序列尾部" data-turn="0" data-flip="0"></div>
      <div class="pass yellow" title="黄色通道:🤖通过时将🟡添加到序列尾部" data-turn="0" data-flip="0"></div>
      <div class="comparator green yellow" title="黄绿比较器:🤖通过时读取序列头部元素根据颜色判断路径" data-turn="0" data-flip="0"></div>
      <div class="pass red" title="红色通道:🤖通过时将🔴添加到序列尾部" data-turn="0" data-flip="0"></div>
      <div class="pass blue" title="蓝色通道:🤖通过时将🔵添加到序列尾部" data-turn="0" data-flip="0"></div>
      <div class="comparator red blue" title="红蓝比较器:🤖通过时读取序列头部元素根据颜色判断路径" data-turn="0" data-flip="0"></div>
      <div class="pass" title="通道" data-turn="0" data-flip="0"></div>
      <div class="trash" title="清除"></div>
    </div>
    <div class="info">说明:鼠标选择上方元件添加到右侧面板中,键盘上下左右旋转,空格翻转。</div>
    <div class="task" id="taskInfo"></div>
    <div class="run">
      <button class="btn" id="runBtn"></button>
      <button class="btn" id="stopBtn"></button>
    </div>
  </div>
  <div><div id="app"></div><div id="io">序列 ← <i>❤️</i><i>💙</i></div><div id="result">结果 →</div></div>
  <div id="mousePick"></div>
</div>

Each component is drawn purely with CSS. For example, comparators use pseudo‑elements to render the plus signs and color them according to their type:

.comparator {margin: 10px 20px; border-bottom-right-radius: 50%; border-bottom-left-radius: 50%;}
.comparator::before {content: '+'; margin-left: -10px;}
.comparator::after {content: '+'; margin-left: 10px;}
.comparator.red::before {color: red;}
.comparator.green::before {color: green;}
.comparator.blue::after {color: blue;}
.comparator.yellow::after {color: orange;}

The right‑hand grid is a 13×13 CSS grid that forms the play board. Its background is a subtle dotted pattern, and each cell is 40 px square:

#app {width: 520px; height: 520px; border-bottom: solid 1px #0002; border-right: solid 1px #0002; background-image: linear-gradient(90deg, rgba(0,0,0,0.15) 2.5%, transparent 2.5%), linear-gradient(rgba(0,0,0,0.15) 2.5%, transparent 2.5%); background-size: 40px 40px; background-repeat: repeat; display: grid; grid-template-columns: repeat(13, 40px); grid-template-rows: repeat(13, 40px);}
#app > div {text-align: center; font-size: 1.8rem; line-height: 48px;}

The robot itself is an absolutely positioned element whose movement is animated with CSS transitions:

#robot {position: absolute; transition: all linear .2s;}
#robot::after {font-size: 1.8rem; content: '🤖'; margin: 5px;}

Interaction begins with a picker that clones a component from the left panel, follows the mouse cursor, and allows rotation (arrow keys) and flipping (space bar). The JavaScript registers mouse, keyboard, and context‑menu events to manage this behaviour:

function enablePicker() {
  const buttons = panel.querySelector('.buttons');
  buttons.addEventListener('mousedown', ({target}) => {
    if (main.className !== 'running' && target !== buttons && target.className) {
      const node = target.cloneNode(true);
      mousePick.innerHTML = '';
      mousePick.appendChild(node);
    }
  });
  window.addEventListener('mousemove', ({x, y}) => {
    mousePick.style.left = `${x - 25}px`;
    mousePick.style.top = `${y - 25}px`;
  });
  window.addEventListener('contextmenu', e => { e.preventDefault(); return false; });
  window.addEventListener('mouseup', ({target}) => {
    if (target.parentNode !== buttons && target.className !== 'normal') {
      mousePick.innerHTML = '';
    }
  });
  window.addEventListener('keydown', ({key}) => {
    const el = mousePick.children[0];
    if (!el || el.className === 'trash') return;
    if (key === 'ArrowRight') el.dataset.turn = 0;
    else if (key === 'ArrowDown') el.dataset.turn = 1;
    else if (key === 'ArrowLeft') el.dataset.turn = 2;
    else if (key === 'ArrowUp') el.dataset.turn = 3;
    else if (key === ' ') {
      let n = Number(el.dataset.flip) || 0;
      el.dataset.flip = ++n % 2;
    }
    if (key.startsWith('Arrow') && el.classList.contains('comparator')) {
      el.dataset.turn = (Number(el.dataset.turn) + 3) % 4;
    }
  });
}

CSS attributes data-turn and data-flip drive rotation and mirroring via transform rules:

*[data-turn="1"] {transform: rotate(.25turn);}
*[data-turn="2"] {transform: rotate(.5turn);}
*[data-turn="3"] {transform: rotate(.75turn);}
*[data-flip="1"] {transform: scale(-1, 1);}
*[data-turn="1"][data-flip="1"] {transform: rotate(.25turn) scale(-1, 1);}
/* …other combinations omitted for brevity … */

The core robot logic is encapsulated in setRobot (places the robot at the start cell) and moveRobot (updates its coordinates and returns a Promise that resolves on transitionend or a timeout fallback):

function setRobot() {
  const start = app.querySelector('.start');
  const row = Number(start.dataset.x);
  const col = Number(start.dataset.y);
  let {x, y} = app.getBoundingClientRect();
  x = x + col * 40;
  y = y + row * 40;
  const el = document.getElementById('robot') || document.createElement('div');
  el.id = 'robot';
  el.style.left = `${x}px`;
  el.style.top = `${y}px`;
  el.dataset.x = x; el.dataset.y = y; el.dataset.row = row; el.dataset.col = col; el.dataset.fromDirection = '';
  document.body.appendChild(el);
}

function moveRobot(direction) {
  let x = Number(robot.dataset.x);
  let y = Number(robot.dataset.y);
  let row = Number(robot.dataset.row);
  let col = Number(robot.dataset.col);
  let fromDirection = '';
  if (direction === 'left') { x -= 40; col--; fromDirection = 'right'; }
  else if (direction === 'right') { x += 40; col++; fromDirection = 'left'; }
  else if (direction === 'up') { y -= 40; row--; fromDirection = 'down'; }
  else if (direction === 'down') { y += 40; row++; fromDirection = 'up'; }
  robot.style.left = `${x}px`;
  robot.style.top = `${y}px`;
  robot.dataset.x = x; robot.dataset.y = y; robot.dataset.row = row; robot.dataset.col = col; robot.dataset.fromDirection = fromDirection;
  return new Promise(resolve => {
    robot.addEventListener('transitionend', () => resolve(robot), {once: true});
    setTimeout(() => resolve(robot), 220);
  });
}

When the robot lands on a cell, checkCell analyses the cell’s children to decide the next direction, any effect on the data sequence, and whether the comparator condition is satisfied. checkState combines this with special handling for the start and flag cells.

function checkCell(cell, fromDirection) {
  const ret = {direction: null, effect: null, type: null, data: false};
  const children = cell.children;
  for (let i = 0; i < children.length; i++) {
    const el = children[i];
    const flip = el.dataset.flip;
    const turn = el.dataset.turn;
    if (el.classList.contains('pass')) {
      ret.type = 'pass';
      // determine direction based on turn and possible cross‑paths
      if (turn === '0') ret.direction = 'right';
      if (turn === '1') ret.direction = 'down';
      if (turn === '2') ret.direction = 'left';
      if (turn === '3') ret.direction = 'up';
      if (el.classList.contains('red')) ret.effect = '🔴';
      if (el.classList.contains('green')) ret.effect = '🟢';
      if (el.classList.contains('yellow')) ret.effect = '🟡';
      if (el.classList.contains('blue')) ret.effect = '🔵';
    } else if (el.classList.contains('comparator')) {
      ret.type = 'comparator';
      const data = getTopData();
      // match colour and set direction accordingly
      if (data === '🔴' && el.classList.contains('red')) { ret.direction = turnToDir(turn); ret.data = true; }
      // similar blocks for green, blue, yellow omitted for brevity
      if (flip === '1') {
        // flip direction when element is mirrored
        if (turn === '0' || turn === '2') {
          if (ret.direction === 'left') ret.direction = 'right';
          else if (ret.direction === 'right') ret.direction = 'left';
        } else {
          if (ret.direction === 'up') ret.direction = 'down';
          else if (ret.direction === 'down') ret.direction = 'up';
        }
      }
    }
  }
  return ret;
}

function checkState() {
  const cell = getRobotCell();
  const fromDirection = robot.dataset.fromDirection;
  let state = {direction: null, effect: null, accepted: false, fromDirection};
  if (cell.className === 'flag') {
    state.accepted = true;
  } else if (cell.className !== 'start') {
    state = {...state, ...checkCell(cell, fromDirection)};
  }
  return state;
}

The run function orchestrates the level execution: it loads the test data, resets the robot, repeatedly checks the current state, moves the robot, updates the I/O sequence, and records success or failure for each test case.

async function run() {
  levelPicker.disabled = true;
  const tests = currentLevel.tests;
  initResult();
  for (let i = 0; i < tests.length; i++) {
    const {data, accept} = tests[i];
    setDataList([...data]);
    setRobot();
    await sleep();
    await moveRobot('down');
    while (true) {
      if (main.className !== 'running') break;
      const state = checkState();
      if (state.direction) {
        if (state.type === 'comparator' && state.data) {
          await Promise.all([moveRobot(state.direction), popData()]);
        } else {
          await moveRobot(state.direction);
          if (state.effect) appendData(state.effect);
        }
      } else {
        break;
      }
    }
    const cell = getRobotCell();
    if (accept === true) {
      appendResult(cell.className === 'flag');
    } else if (typeof accept === 'string') {
      if (cell.className !== 'flag') appendResult(false);
      else appendResult(accept === getIOData());
    } else {
      appendResult(cell.className !== 'flag');
    }
    await sleep(500);
  }
  // UI cleanup omitted for brevity
}

Level progress is persisted in localStorage so that a solved board is restored on subsequent visits:

function saveLevel() {
  const {level} = currentLevel;
  const data = {level, cells: []};
  const cells = app.children;
  for (let i = 0; i < 169; i++) {
    const cell = cells[i];
    if (cell.children.length) {
      for (let j = 0; j < cell.children.length; j++) {
        const item = cell.children[j];
        data.cells.push({
          state: item.className,
          turn: item.dataset.turn,
          flip: item.dataset.flip,
          idx: Number(cell.dataset.x) * 13 + Number(cell.dataset.y)
        });
      }
    }
  }
  localStorage.setItem(`manufactoria-level-${level}`, JSON.stringify(data));
}

Finally, a dropdown populated from the levels array lets the player switch between puzzles, automatically loading the board layout, initial data sequence, and any saved component placements.

function initLevelPicker() {
  const len = levels.length;
  levelPicker.innerHTML = '';
  for (let i = 0; i < len; i++) {
    const option = new Option(i + 1, i);
    levelPicker.appendChild(option);
  }
  levelPicker.addEventListener('change', () => { loadLevel(levelPicker.value); });
  loadLevel(levelPicker.value);
}

initLevelPicker();

Through these concise HTML, CSS, and JavaScript fragments the full Manufactoria experience is recreated, demonstrating how simple web technologies can model classic programming puzzles, support interactive state machines, and provide a platform for teaching sequencing, conditionals, and loops.

frontendJavaScriptgame developmentCSSHTML
ByteFE
Written by

ByteFE

Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.