BashTeacher.com

Course: II1305 - Project in Information and Communication Technology, link

BashTeacher.com is a website where you learn Bash by playing through interactive lessons in a real terminal. It was built together with a few other students during the spring 2026 term, in four one-week Scrum sprints between April and May. The project ended with a public demo of the finished product. Everything was built from scratch. The frontend is React, and the backend is a Node.js server that manages the database and spins up an isolated Docker container for every player.

Screenshot of the BashTeacher.com landing page
The landing page

Lobby

After logging in, players land in a lobby showing their lesson progress as a winding path. Each node is one lesson: completed lessons are green, the current one is highlighted with “YOU”, and lessons further ahead stay locked until the previous ones are done.

Screenshot of the BashTeacher lobby, showing lessons as nodes on a winding path
The lobby, with the lesson path on the right

Stack

Frontend
  • React + TypeScript, built with Vite
  • xterm.js for the in-browser terminal
  • Socket.io client for the terminal connection
Backend
  • Node.js + Express, TypeScript
  • PostgreSQL for users, lessons and progress
  • GitHub OAuth login (Passport)
  • Docker, spawned per player through node-pty

Per-session sandboxed terminal

Each player gets a real terminal in the browser, connected over a WebSocket to the backend. When a player connects, the backend spawns a brand new Docker container just for that connection using node-pty, so keystrokes and output stream both ways like a real SSH session:

const containerName = `sandbox-${socket.id}`;

const ptyProcess = pty.spawn('docker', [
  'run',
  '--name', containerName,
  '--rm',
  '--network', 'none',
  '--memory', '256m',
  '--memory-swap', '256m',
  '--pids-limit', '64',
  '--cpus', '1',
  '-it', imageName
], {
  name: 'xterm-256color',
  cols: 80,
  rows: 24,
});

ptyProcess.onData((data) => socket.emit('output', data));
socket.on('input', (data) => ptyProcess.write(data));

socket.on('disconnect', () => {
  ptyProcess.kill();
  exec(`docker rm -f ${containerName}`);
});

Since players can run any Bash command they want, the container needed to be locked down. --network none means it can’t reach the internet or other containers. The memory and --pids-limit flags stop a mistake like a fork bomb from taking down the server. --rm, together with an explicit docker rm -f on disconnect, makes sure containers never pile up.

Visualizing the filesystem

Next to the terminal is a separate file-tree panel showing the container’s real filesystem, but folders start out unexplored until the player actually visits them. This was meant to teach that the filesystem is something you navigate, not something you can see all at once.

Screenshot of a lesson in BashTeacher, showing the terminal next to the file tree
Terminal and file tree during a lesson

After every Enter keypress, the backend lists the directories and files inside the container over docker exec, and combines that with a discovered-paths file that a small Go program inside the container keeps updated. Each path is then marked as known, unknown, or hidden:

const discoveredChildren = discoveredMap[parentAbsPath] || [];
const isDiscovered = discoveredChildren.includes(part);

let childStatus: 'known' | 'unknown' | 'hidden';
if (part.startsWith('.')) {
  childStatus = isDiscovered ? 'hidden' : 'unknown';
} else {
  childStatus = isDiscovered ? 'known' : 'unknown';
}

The frontend renders that tree recursively, one node at a time. Each node picks its icon based on its status, and only renders a <ul> of children if the status isn’t unknown. That way, even if the backend already sent the full subtree, an unexplored folder never gets its children drawn:

function renderNode(node: FileNode) {
  let iconSrc = iconFile;
  if (node.isDirectory) {
    switch (node.status) {
      case 'unknown':
        iconSrc = iconUnknownClosed;
        break;
      case 'known':
        iconSrc = node.isOpen ? iconKnownOpen : iconKnownClosed;
        break;
      case 'hidden':
        iconSrc = node.isOpen ? iconHiddenOpen : iconHiddenClosed;
        break;
    }
  }

  return (
    <li key={node.nodeKey}>
      <img src={iconSrc} alt="icon" />
      <span>{node.name}</span>

      {node.status !== 'unknown' && node.children && node.children.length > 0 && (
        <ul>
          {node.children.map((child) => renderNode(child))}
        </ul>
      )}
    </li>
  );
}

Progress, shop and friends

To keep players motivated beyond just the lessons, BashTeacher keeps track of daily streaks and unlocks achievements as lessons are completed. Coins earned from lessons can be spent in a shop on clothes and accessories for a penguin mascot, and players can add friends and compare progress with them on a leaderboard.

Screenshot of a player's profile page in BashTeacher, showing streak, achievements and lessons completed
A player's profile page

Whenever a lesson is completed, the backend recalculates the player’s streak from the date of their last completed lesson, rather than just incrementing a counter. That way a player who skips a day automatically has their streak reset:

const resolveNextStreak = (
  currentStreak: number,
  lastStreakActivityOn: string | Date | null,
  activityDate: Date = new Date()
) => {
  if (!lastStreakActivityOn) {
    return 1;
  }

  const diffInDays = daysBetween(activityDate, lastStreakActivityOn);

  if (diffInDays <= 0) return currentStreak;   // Already counted today
  if (diffInDays === 1) return currentStreak + 1; // Active yesterday, streak continues
  return 1; // Missed a day, streak resets
};

The same lesson-completion step also checks whether the new streak or lesson count crosses any achievement thresholds, and unlocks them if they haven’t been unlocked already:

if (nextStreak >= 5) {
  const alreadyUnlocked = await hasAchievement(githubId, FIVE_DAY_STREAK_ID);
  if (!alreadyUnlocked) {
    await unlockAchievement(githubId, FIVE_DAY_STREAK_ID);
    unlockedAchievements.push(FIVE_DAY_STREAK_ID);
  }
}