Our main idea is to make a board game digitally interactive with the help of the AprilTag Library. The board game is based on Marble Maze, in which two players must collaborate to prevent the marbles from falling out of the maze. The maze is also customizable by rearranging its sections. In the digital version, the marbles interact with an alphabet grid.

We chose the Cobra typeface because it has a playful appearance and reminds us of the cover art of many board games. After selecting the typography, we created several designs inspired by typical board game covers. However, we realized that the results appeared too complex, and we did not have a clear vision of how the physical and digital components would interact. After several sketches, we made a bold decision to design a much simpler interface based on an alphabet grid. This design choice was intended to visually track the movement of the marbles by highlighting the corresponding letters.

Storyboard: How the colour trackings influence the specimen







We drew inspiration from analogue works like board games or intricate wood builds and colourful vs wooden aesthetics. For the digital part (specimen) we wanted to replicate the grid from our maze choice.

Analogue Inspiration

Digital Inspiration

Specimen Design Process
Before explaining the maze board, it’s important to describe the structure of the board itself. The board consists of three main components: the base, and the straight and curved wall pieces. Some of these wall pieces have holes in them. The baseboard has walls around it to prevent the marbles and wall parts from falling off. The base plate contains a total of 25 holes.
The inspiration of the maze is from the typeface Cobra provided by the team Unstated. Since this typography is based on the square grid system, the forms of each grid were used: The straight or curved parts of the typography with the lowest weight.



After the design was finalized, a SVG plan was made and the wood cut with the laser cutter.
We also wanted to integrate holes into the maze to make it more similar to the original Marble Maze game. For this reason, we added holes to some of the wooden wall pieces.
One challenge we had was building curved walls of the maze. However, an approach was found to cut the wood in way, so that it would bend the walls. With “Laser Kerfing”, it was possible to cut the wood with the laser cutter.

Some parts broke, so we had to adjust some factors

The first prototype of the wall part


At first, some prototypes of the removable parts were made before building the whole maze. There were some challenges, for example, if the wall was bent keenly, then it would break. So, the right balance of the cutting method and bendability needed to be found. At first, the plates didn’t have small holes to stick with the walls together but after having some difficulties to piece them together, the plates got tiny holes and the walls got connecting elements.
For the baseboard, we gave 4 handles, so it would be possible that 2 players could play the board.

Final interaction between analog and digital
A main component in this project was the interaction between the physical and digital part. What ideas can we develop? How can we add the interactions through colour detection and how can that data influence our specimen grid? The following are some challenges we faced and learnings from working at the intersection of physical and digital:
Feel free to also check out the source code on the Github Repo : )
01) canvas flip, first just horizontal, second also vertical
// canvas flip:
ctx.save();
ctx.scale(-1, 1); // horizontal flip
ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height); //immer das wo flipped wird muess -canvas.xyz sii statt null, direkt nach (video, ...)
ctx.restore(); // so the other stuff is working on the not flipped canvas
//if it's not flipping:
// ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// canvas flip:
ctx.save();
ctx.scale(-1, -1); // horizontal + vertical flip
ctx.drawImage(video, -canvas.width, -canvas.height, canvas.width, canvas.height); //immer das wo flipped wird muess -canvas.xyz sii statt null, direkt nach (video, ...)
ctx.restore(); // so the other stuff is working on the not flipped canvas02) key event listeners for easy handling
window.addEventListener("keydown", (e) => {
switch (e.key) {
case "r":
case "R":
resetGridStyles();
break;
case "0":
case "1":
case "2":
const newDevice = parseInt(e.key);
if (device !== newDevice) {
device = newDevice;
console.log(`switching to device ${device}`);
startCamera();
}
break;
case "d":
case "D":
showDetections = !showDetections;
console.log(`Show detections: ${showDetections ? "ON" : "OFF"}`);
break;
case "s":
case "S":
shuffleGrid();
break;
}
});03) actually work with our detections: always the first green and blue object
// GRID Interactions:
// find first green detection
const greenObject = detections.find((det) => det.color === "green");
const blueObject = detections.find((det) => det.color === "blue");
if (greenObject) {
const { x, y } = greenObject;
// green object specific code04) variable for last cell so we know if it's been there before or newly in a cell, so stuff only happens once
if (cellId !== lastGreenCellId) {
console.log(`Green entered new cell: ${cellId}`);
// only run if lastGreenCellId was valid cell
if (lastGreenCellId !== -1) {
const previousCell = document.getElementById(
`id` + lastGreenCellId.toString()
);
if (previousCell) {
previousCell.style.color = "white";
}
}
lastGreenCellId = cellId;05) handle video input to switch easily between devices, press 0, 1, 2 to access them while filming
async function startCamera() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter((d) => d.kind === "videoinput");
console.log("Video devices found:", videoDevices);
// Try the first device (usually external webcam)
let stream;
if (videoDevices.length > 0) {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: videoDevices[device].deviceId }, // <-------- used to change here, now in 'device' variable
width: { ideal: 1137 },
height: { ideal: 640 }, // Request preferred height 1080
frameRate: { ideal: 60 },
},
});
} catch (err) {
console.warn(
"Failed to access external webcam, falling back to default camera.",
err
);
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1137 },
height: { ideal: 640 },
},
});
}
}06) for green interaction we're changing font size by rows. but realised it shouldn't flicker between two modes so there's a buffer zone called "middle". the two modes are top to bottom or bottom to top (font size)
if (greenObject) {
const { y } = greenObject;
// Define the vertical boundaries for the zones based on the grid
const topZoneEndY = trackingSquareOffsetY + 2 * gridCellSize;
const bottomZoneStartY = trackingSquareOffsetY + 3 * gridCellSize;
let currentZone = "middle"; // Default to the dead zone
if (y < topZoneEndY) {
currentZone = "top";
} else if (y > bottomZoneStartY) {
currentZone = "bottom";
}
// Only trigger the change if we enter the top or bottom zone
// and it's a different zone than the last one.
if (currentZone !== "middle" && currentZone !== lastGreenSide) {
// console.log(`Green object moved to the ${currentZone} zone.`);
lastGreenSide = currentZone; // Update the state
const minSize = 40; // smallest font size
const maxSize = 300; // largest font size
const isTop = currentZone === "top";
for (let i = 1; i <= 25; i++) {
const cell = document.getElementById(`id` + i.toString());
if (cell) {
const row = Math.floor((i - 1) / gridNum);
let size;
if (isTop) {
// Big text on top, smaller below
size = maxSize - row * ((maxSize - minSize) / (gridNum - 1));
} else {
// Small text on top, bigger below
size = minSize + row * ((maxSize - minSize) / (gridNum - 1));
}07) creating the divs and spans for the 5x5 grid cells for the specimen, problem: realised much later an id shouldn't start with a number so we changed to #id1 instead of #1
const grid = document.getElementById("grid");
// Create the 25 cells (A–Z, skipping J if desired)
const letters = "ABCDEFGHIKLMNOPQRSTUVWXYZ"; // note: skipping 'J' like your sample
letters.split("").forEach((letter, index) => {
const cell = document.createElement("div");
cell.classList.add("cell");
cell.id = `id` + (index + 1); // gives id="1", id="2", etc.
//inner span for the letter
const letterSpan = document.createElement("span");
letterSpan.classList.add("letter");
letterSpan.textContent = letter;
cell.appendChild(letterSpan);
grid.appendChild(cell);
});08) custom ease functions in CSS so the animations look more slick & cool :-)
body {
/* custom easing function */
--elegant-ease: cubic-bezier(0.65, 0, 0.35, 1);
--overshoot-ease: cubic-bezier(0.34, 1.56, 0.64, 1);