Truth or Drink#

Play

🧩 javascript icon 🌐 web-components icon

Truth or Drink

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
mobile Screenshot