Truth or Drink

๐Ÿ•น๏ธJavaScript tag๐ŸŒweb components tag Play

Description

Truth or Drink is a party game where players ask each other questions they wouldnโ€™t dare to anywhere else. You get a point for every question another player avoids with a drink. Iโ€™ve implemented a set of questions using a custom web component.

Aim

Create a unique web component to display each question.

Milestones

Applying my aim to the game, I created some milestones:

  1. Set up a touch-based interface for navigating through questions (since thisโ€™ll most likely run on mobile phones).

  2. Add filtering so that certain types of question can be removed.

  3. Add the ability to see previous questions.

  4. Configure PWA functionalty for on and offline use.

Implementation

Classes

Card

The Card class has two main attributes that can be set, text and round. Storing the round alongside the question text allows for easy filtering. To speed up development, I added getters for symbol and color. Coupling the UI properties to the class in this way isnโ€™t strictly best practice, however for a small application with a limited use-case I think I can get away with it for now. Overriding the default toString() method allowed me to set up easy comparison and sorting.

class Card {
    constructor(text = null, round = null, extraSymbol = null) {
        this._text = text;
        this._round = round;
    }
    get text() {
        return this._text || "";
    }
    set text(val) {
        if (val) {
            this._text = val;
        }
    }
    set round(val) {
        if (val) {
            this._text = val;
        }
    }
    get round() {
        return this._round || "";
    }

    get extraSymbol() {
        return this._extraSymbol || "";
    }
    set extraSymbol(val) {
        this.extraSymbol = val;
    }
    get symbol() {
        let symbols = {
            chill: "๐Ÿ˜Ž",
            happy: "๐ŸŒž",
            afterDark: "๐ŸŒ™",
            dirty: "๐Ÿ”ž",
        };
        let result = symbols[this.round] ? symbols[this.round] + this.extraSymbol : "";
        return result || "๐Ÿบ๐Ÿท";
    }
    get color() {
        let colors = {
            chill: "teal",
            happy: "gold",
            afterDark: "navy",
            dirty: "red",
        };
        return colors[this.round] || "grey";
    }
    get json() {
        return { text: this.text, round: this.round };
    }
    toString() {
        return JSON.stringify(this.json);
    }
    valueOf() {
        return (this.text + "").normalize().toLowerCase();
    }
}

ObjectSet

The default JavaScript Set treats different instances of an object with equal attributes and properties as unique. This means the default Set.has() function cannot be used for comparing Cards. This class acts as a drop-in replacement that can handle objects by comparing their string values. Should the Truth or Drink application expand or this class see use in other places then it would be prudent to perform deep comparison of object attributes and methods instead of just string values.

class ObjectSet extends Set {
    has(val) {
        if (super.has(val)) {
            return true;
        } else {
            return Array.from(super.values()).some((item) => item + "" === "" + val);
        }
    }
    add(val) {
        if (!this.has(val)) {
            super.add(val);
        }
    }
    delete(val) {
        if (this.has(val)) {
            super.delete(val);
        }
    }
}

Deck

The Deck is a container for ObjectSets of Cards. It tracks which questions have been used and which remain based on the rounds the player has allowed. The Deck endpoint attribute is set so that GPT-3 questions can be fetched remotely. An emoji symbol is also added to these questions from endpointSymbol in order to differentiate them visually from the standard questions.

class Deck {
    constructor(
        questions,
        rounds = ["chill", "happy", "afterDark"],
        endpoint = null,
        endpointSymbol = null
    ) {
        this._used = new ObjectSet();
        this._remaining = new ObjectSet(questions);
        this._rounds = new Set(rounds);
        this.endpoint = endpoint;
        this.endpointSymbol = endpointSymbol;
        this._lastUsed = null;
        this._autoReset = true;
    }
    get remaining() {}
    get length() {}
    get rounds() {}
    get endpointSymbol() {}
    set endpointSymbol(val) {}
    get endpoint() {}
    set endpoint(path) {}
    addCard(card) {}
    removeCard(card) {}
    addQuestion(text, round, extraSymbol) {} //a wrapper for addCard()
    next() {}
    allow(round, isAllowed = true) {}
    isAllowed(round) {}
    _fetchEndpoint() {}
    reset() {}
    shuffle() {}
}

Card Component

The application holds up to three cards in the UI at any one point. Using the events provided by flickity, a new Card component is added to the slider every time the user moves right. The oldest card is then removed in order to avoid clutter and maintain performance.

class CardElement extends HTMLElement {
    constructor() {
        super();
        this.useShadowDOM = true;
    }
    static get observedAttributes() {
        return ["text", "round", "face-down"];
    }
    _upgradeProperty(prop) {
        //check for properties set before this component was added to the DOM
        if (this.hasOwnProperty(prop)) {
            let value = this[prop];
            delete this[prop];
            this[prop] = value;
        }
    }
    connectedCallback() {
        //inserted into DOM
        //check we have all of the properties we need
        for (let a of CardElement.observedAttributes) {
            this._upgradeProperty(a);
        }
    }
    disconnectedCallback() {} //removed from DOM
    attributeChangedCallback(name, oldValue, newValue) {
        //observed attributes have been changed
        this._updateChildren(name);
        this._updateStyle(name);
    }
    _updateStyle() {
        var childNodes = this.root.childNodes;
        //ensure there is always only one style element on the shadowDOM
        if (!Array.from(childNodes).some((child) => child.nodeName === "STYLE")) {
            this.root.appendChild(document.createElement("style"));
        }
        //keys in this object are used as selectors and their values are the CSS attributes applied to them
        //this allows the properties to be read and updated with an easy to read syntax
        let obj = {
            ".classname": {
                display: "inline",
                color: "red",
            },
            "#id": {
                display: "none",
            },
        };
        //the style tag's innerHTML is produced by looping over the object.
        for (var i = 0; i < childNodes.length; i++) {
            if (childNodes[i].nodeName === "STYLE") {
                let str = "";
                for (const [selector, props] of Object.entries(obj)) {
                    let propStr = "";
                    for (const [key, val] of Object.entries(props)) {
                        propStr += `${key}:${val};`;
                    }
                    str += `${selector}{${propStr}}`;
                }
                childNodes[i].textContent = str;
            }
        }
    }
    get width() {}
    get height() {}
    get _label() {}
    get duration() {}
    set duration(val) {} //set the length of the flip using a valid duration string

    _addChildren() {
        //reset the component
        if (this.useShadowDOM) {
            this.shadowRoot.childNodes.forEach((child) => this.shadowRoot.removeChild(child));
        } else {
            this.childNodes.forEach((child) => this.removeChild(child));
        }
        //recreate the component
        let container = document.createElement("div");
        container.className = "container";
        let inner = document.createElement("div");
        inner.className = "inner";
        container.appendChild(inner);
        //front
        let front = document.createElement("div");
        front.className = "front";
        inner.appendChild(front);
        let roundEl = document.createElement("span");
        roundEl.innerText = this._label;
        let textEl = document.createElement("h1");
        textEl.innerText = this.text;
        front.appendChild(roundEl);
        front.appendChild(textEl);
        //back
        let back = document.createElement("div");
        back.className = "back";
        let img = document.createElement("img");
        img.src = "/assets/icons/logo.svg";
        back.appendChild(img);
        inner.appendChild(back);

        if (this.useShadowDOM) {
            this.shadowRoot.appendChild(container);
        } else {
            this.appendChild(container);
        }
    }
    _updateChildren(name) {
        this._updateStyle(`children (${name})`);
        switch (name) {
            case "text":
                this.root.querySelector("h1").innerText = this.text;
                break;
            case "round":
                this.root.querySelector("span").innerText = this._label;
                break;
            default:
                break;
        }
    }

    get root() {
        if (this.useShadowDOM && !this.shadowRoot) {
            this.attachShadow({ mode: "open" });
        }
        return this.useShadowDOM ? this.shadowRoot : this;
    }

    _draw() {
        //remove all existing children so that DOM elements are updated
        while (this.root.firstChild) {
            let el = this.root.firstChild;
            // console.log(el);
            this.root.removeChild(el);
        }
        this._addChildren();
        this._updateStyle("drawing");
    }
    get card() {
        return new Card(this.text, this.round);
    }
    set card(val) {
        if (val && val instanceof Card) {
            this.text = val.text;
            this.round = val.round;
        }
    }
    get text() {
        return this.getAttribute("text") || "";
    }
    get round() {
        return this.getAttribute("round") || "";
    }
    set text(val) {
        if (val && val != this.text) {
            this.setAttribute("text", val);
        }
    }
    set round(val) {
        if (val && val != this.round) {
            this.setAttribute("round", val);
        }
    }
    get faceDown() {
        return this.hasAttribute("face-down");
    }
    set faceDown(val) {
        val = val ? true : false; //force boolean
        if (val != this.faceDown) {
            if (val) {
                this.setAttribute("face-down", "");
            } else {
                this.removeAttribute("face-down");
            }
        }
    }
}
window.customElements.define("question-card", CardElement);

![mobile screenshot](/files/truth-or-drink/tod.jack.do_(iPhone 6_7_8).png)