Skip to content

Instantly share code, notes, and snippets.

@RascalTwo
Last active October 25, 2022 15:46
Show Gist options
  • Save RascalTwo/4161f2c1672b3e04d28cc934651eb95f to your computer and use it in GitHub Desktop.
Save RascalTwo/4161f2c1672b3e04d28cc934651eb95f to your computer and use it in GitHub Desktop.
Javascript vs Brython Hangman

Hangman Comparison

Classic Hangman with a digital theme!

Gameplay

Side-by-side comparison of using Javascript and Brython to add identical functionality to a derivative game of Hangman.

You can use the keyboard enter guesses, the Enter and Escape keys dismiss the current message, and ? toggles the hint


In order to experience the Brython version, these files must be on a server. Easiest way to do this is to start a local server in the directory:

python3 -m http.server

How It's Made

Tech Used: HTML, CSS, JavaScript, Brython, Python

Originally made in JavaScript for a game of DevWars, then rewritten in Brython - the logic is nearly identical, a word and hint are picked from the list, and the user guesses letters until they either guess the word or lose all their lives.

Optimizations

The ability to easily customize the word & hint list would be an excellent next feature to add.

Lessons Learned

While it does require JavaScript knowledge of what one is writing, I learned that if one truly wanted to they could write client-side functionality in Brython that would be indistinguishable from a JavaScript version - of course knowing the Web API ecosystem usually requires some JavaScript knowledge, so at that point you might as well be writing in JavaScript.

<head>
<title>Hangman</title>
<link rel="stylesheet" href="index.css" />
</head>
<body onload="brython()">
<span id="fade"></span>
<span id="clue"></span>
<main>
<span id="word"></span>
<span id="keyboard"></span>
<span id="lives">
<picture id="system32">
<img src="https://icons.veryicon.com/png/Folder/Vista%20Folders%203/system32.png" />
</picture>
<picture id="recycle-bin">
<img src="http://icons.iconarchive.com/icons/iconshock/vista-general/256/trash-icon.png" />
</picture>
</span>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/brython/3.8.8/brython.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/brython/3.8.8/brython_stdlib.js"></script>
<script type="text/python" src="index.py"> </script>
</body>
<html>
* {
transition: all 1s;
user-select: none;
}
*:focus {
outline: none;
}
:root {
--monofont: "Courier New", Courier, monospace;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
main {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: space-around;
}
main > * {
margin-bottom: 1vh;
}
#keyboard {
display: inline-block;
background-color: wheat;
padding: 0.5em;
border-radius: 1.5em;
}
.row {
display: block;
}
.key {
background-color: white;
font-family: var(--monofont);
border-width: 0.5em;
border-style: outset;
border-color: black;
padding: 0.5em;
margin: min(0.25vw, 0.25em);
border-radius: 10%;
transition: all 0.25s;
}
.key:hover:not(:active):not(:disabled) {
background-color: black;
color: white;
transform: scale(0.95);
/*border-style: groove;*/
}
.key.wrong {
background-color: crimson;
}
.key.right {
background-color: springgreen;
}
.key:active, .key:disabled {
border-style: inset;
}
main > *:last-child {
margin-bottom: 0;
}
#keyboard {
display: inline-block;
background-color: wheat;
padding: 0.5em;
border-radius: 1.5em;
}
.row {
display: block;
}
.key {
background-color: white;
font-family: var(--monofont);
border-width: 0.5em;
border-style: outset;
border-color: black;
padding: 0.5em;
margin: min(0.25vw, 0.25em);
border-radius: 10%;
transition: all 0.25s;
}
.key:hover:not(:active):not(:disabled) {
background-color: black;
color: white;
transform: scale(0.95);
/*border-style: groove;*/
}
.key.wrong {
background-color: crimson;
}
.key.right {
background-color: springgreen;
}
.key:active, .key:disabled {
border-style: inset;
}
#clue {
position: absolute;
left: 0;
top: 0;
background-color: rgb(255, 255, 125);
padding: 0.5em;
border-bottom-right-radius: 1em;
cursor: pointer;
}
#word {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
background-color: rgb(114, 239, 255);
border-radius: 4em;
padding: 1em;
font-family: var(--monofont);
}
.letter {
border-bottom: 2px solid black;
padding: 0 0.25em 0 0.25em;
font-size: 2em;
margin-right: 1vw;
}
.letter.space {
border: 0;
}
.letter:last-child {
margin-right: 0;
}
html {
background: url(https://img.wallpapersafari.com/desktop/1920/1080/33/61/tun7BE.png) no-repeat center center fixed;
}
#fade.active {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
background-color: rgba(0, 0, 0, 0.75);
}
main > *:first-child {
margin-bottom: 0;
}
#lives {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
right: 0;
bottom: 0;
left: 0;
height: 10vh;
}
@media only screen and (max-width: 500px) {
#lives {
background-color: transparent;
}
}
#lives picture {
position: absolute;
transition: all 1s;
top: 1vh;
}
#lives picture::before {
position: absolute;
color: white;
bottom: -1.5em;
content: attr(data-text);
left: 50%;
transform: translateX(-50%);
text-shadow: 0.15em 0.15em black;
font-weight: lighter;
white-space: nowrap;
}
#lives img {
height: 5vh;
}
#system32 {
left: 5vw;
}
#recycle-bin {
right: 5vw;
z-index: 2;
}
#recycle-bin.message {
cursor: pointer;
}
#recycle-bin.message::after {
position: absolute;
background-color: white;
width: 12.5vw;
height: 12.5vh;
border-radius: 1em;
padding: 0.25em;
border-bottom-right-radius: 0;
top: -13vh;
left: -13vw;
content: attr(data-message-text);
white-space: pre-wrap;
cursor: pointer;
}
<a href="js.html">Javascript Version</a>
<a href="brython.html">Brython Version</a>
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const randFrom = arr => arr[Math.floor(Math.random() * arr.length)]
const elementFactory = (tag, props={}, attrs={}, children=[], custom=(elem) => undefined) => {
const elem = document.createElement(tag)
Object.entries({ attrs, props }).forEach(([type, obj]) =>
Object.entries(obj).forEach(([key, value]) => type === 'attrs'
? elem.setAttribute(key, value)
: elem[key] = value
));
elem.appendChild(children.reduce((frag, child) => {
frag.appendChild(child);
return frag;
}, document.createDocumentFragment()));
custom(elem);
return elem;
}
const WORD_BANK = [
['Mountain', 'Everest'],
['Rascal Two', 'Yours truly!'],
['DevWars', '#1 Game show'],
['Mambadev', 'Best Dev'],
['Android', '#1 Mobile OS'],
['VSCode', '#1 Editor'],
['GitHub', 'Code Collab'],
['CSS', 'Make things pretty!'],
['JavaScript', 'Make things functional!'],
['Java', 'Not JavaScript'],
['COVID-19', 'Destroyer of Mankind'],
['1969', 'Internet Birth-Year'],
['Hangman', 'This']
].map(([word, clue]) => [word.toUpperCase(), clue]);
const MAX_LIVES = 5;
let playedWords = [];
let currentWC
let hitLetters = [];
let consecWins = 0;
let indentRows = window.innerWidth > 450
const KEYBOARD_ROW_CHARS = [
'1234567890-',
'qwertyuiop',
'asdfghjkl',
'zxcvbnm'
]
$('#keyboard').appendChild(KEYBOARD_ROW_CHARS.reduce((frag, row, index) => {
frag.appendChild(row.split('').map(char => char.toUpperCase()).reduce((rowFrag, char) => {
rowFrag.append(elementFactory('button', { className: 'key', textContent: char, onclick: e => handleKeyClick(e.target, char) }))
return rowFrag;
}, elementFactory('span', { className: 'row' }, { style: indentRows ? `margin-left: ${index * 1}em` : ''})));
return frag
}, document.createDocumentFragment()));
const start = () => {
if (WORD_BANK.length == playedWords.length) playedWords = [];
$$('.key').forEach(key => key.classList.remove('right', 'wrong') || key.removeAttribute('disabled'));
while (true){
var [word, clue] = randFrom(WORD_BANK);
if (!playedWords.includes(word)) break;
}
playedWords.push(word);
currentWC = { word, clue };
const clueHolder = $('#clue');
clueHolder.dataset.clue = clue;
clueHolder.textContent = '?\u20DD';
const wordHolder = $('#word');
wordHolder.innerHTML = ''
wordHolder.appendChild(word.split('').reduce((frag, char) => {
frag.appendChild(elementFactory('span', { className: 'letter' + (char === ' ' ? ' space' : ''), innerHTML: '&nbsp;' }, {}, [], letter => letter.dataset.char = char))
return frag;
}, document.createDocumentFragment()));
if (!lives.lives) lives.changeLives(+1000);
}
const clicky = new Audio('https://www.fesliyanstudios.com/play-mp3/642')
const handleKeyClick = async (key, char) => {
if (key.disabled) return;
key.disabled = true;
//clicky.currentTime = 0.375;
clicky.play()
const hit = currentWC.word.includes(char.toUpperCase())
key.classList.add(hit ? 'right' : 'wrong');
if (hit){
hitLetters.push(char);
const letters = $$('.letter')
letters.filter(letter => letter.dataset.char == char).map(letter => letter.textContent = char || letter)
const visibleLetters = letters.filter(letter => letter.textContent == letter.dataset.char || letter.dataset.char === ' ')
if (visibleLetters.length != currentWC.word.length) return;
await showMessage(`You won the round!
The word was "${currentWC.word}"`, true)
consecWins += 1
if (consecWins === 3) await showMessage('You won the whole game!', true) || lives.changeLives(+1000);
start();
}
else if (lives.changeLives(-1) == 0){
await showMessage(`You lost!
The word was "${currentWC.word}"`, true);
consecWins = 0;
start();
}
}
$('#clue').addEventListener('click', e => {
const clue = e.target;
if (!clue.dataset.clue) return;
clue.textContent = clue.dataset.clue == clue.textContent ? '?\u20DD' : clue.dataset.clue
})
const waitFor = check => new Promise(async r => {
while (!check()){
await new Promise(rt => setTimeout(rt, 250));
}
r()
})
const showMessage = (text, blocking=false) => {
const bin = $('#recycle-bin');
bin.classList.add('message');
bin.dataset.messageText = text
const fade = blocking && $('#fade');
if (blocking) fade.classList.add('active');
bin.addEventListener('click', hideMessage.bind(null, bin, fade))
return waitFor(() => !bin.classList.contains('message')).then(() => console.log('done'))
}
const messageIsShowing = () => $('#recycle-bin').classList.contains('message');
const hideMessage = (bin, fade) => {
bin.classList.remove('message');
bin.removeEventListener('click', hideMessage);
if (fade) fade.classList.remove('active')
}
window.addEventListener('resize', () => {
indentRows = window.innerWidth > 450;
$$('.row').forEach((row, index) => row.style = indentRows ? `margin-left: ${index * 1}em` : '');
lives.updateDisplay();
})
window.addEventListener('keyup', e => {
const { key } = e;
if (messageIsShowing()){
if (['Enter', 'Escape'].includes(key)) $('#recycle-bin').click();
return;
}
if (KEYBOARD_ROW_CHARS.some(row => row.includes(key.toLowerCase()))){
const elem = document.evaluate(`//*[@class='key' and text()='${key.toUpperCase()}']`, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
if (!elem || elem.disabled) return;
elem.click();
}
if (key === '?') $('#clue').click();
});
class LifeCounter{
constructor(maximum){
this.sys32 = $('#system32');
this.recy = $('#recycle-bin')
this.lives = maximum;
this.maximum = maximum;
this.updateDisplay();
}
setPercentage(percent){
this.sys32.style.left = (80 * percent * 0.01 + 5) + 'vw';
this.sys32.style.transform = percent == 100 ? 'scale(0) rotate(360deg)' : '';
}
updateDisplay(){
this.sys32.dataset.text = '❤️'.repeat(this.lives)
this.recy.dataset.text = 'Lives'
this.setPercentage(100 - (this.lives / this.maximum * 100))
}
changeLives(change){
this.lives += change;
if (this.lives < 0) this.lives = 0;
if (this.lives > this.maximum) this.lives = this.maximum
this.updateDisplay();
return this.lives
}
}
const lives = new LifeCounter(MAX_LIVES);
start();
import random
import math
from browser import document, alert, window, aio
S = lambda query: document.querySelector(query)
SS = lambda query: document.querySelectorAll(query)
rand_from = lambda lst: lst[math.floor(random.random() * len(lst))]
def element_factory(tag, props={}, attrs={}, children=[], custom=lambda _: None):
elem = document.createElement(tag)
for type, obj in { 'attrs': attrs, 'props': props }.items():
for key, value in obj.items():
if type == 'attrs':
elem.attrs[key] = value
else:
try:
setattr(elem, key, value)
except:
pass
frag = document.createDocumentFragment()
for child in children:
frag <= child
elem <= frag
custom(elem)
return elem
WORD_BANK = [(word.upper(), clue) for word, clue in [
['Mountain', 'Everest'],
['Rascal Two', 'Yours truly!'],
['DevWars', '#1 Game show'],
['Mambadev', 'Best Dev'],
['Android', '#1 Mobile OS'],
['VSCode', '#1 Editor'],
['GitHub', 'Code Collab'],
['CSS', 'Make things pretty!'],
['JavaScript', 'Make things functional!'],
['Java', 'Not JavaScript'],
['COVID-19', 'Destroyer of Mankind'],
['1969', 'Internet Birth-Year'],
['Hangman', 'This']
]]
MAX_LIVES = 5
playedWords = []
currentWC = None
hitLetters = []
consecWins = 0
indent_rows = window.innerWidth > 450
KEYBOARD_ROW_CHARS = ['1234567890-','qwertyuiop','asdfghjkl','zxcvbnm']
frag = document.createDocumentFragment()
for index, row in enumerate(KEYBOARD_ROW_CHARS):
rowFrag = element_factory('span', { 'class_name': 'row' }, { 'style': f'margin-left: {index * 1}em' if indent_rows else ''})
for char in [char.upper() for char in row]:
rowFrag <= element_factory('button', { 'class_name': 'key', 'text': char, 'onclick': lambda e, char=char: aio.run(handle_key_click(e.target, char)) })
frag <= rowFrag
S('#keyboard') <= frag
def start():
global playedWords, currentWC
if len(WORD_BANK) == len(playedWords):
playedWords = []
for key in SS('.key'):
key.classList.remove('right', 'wrong') or key.removeAttribute('disabled')
while True:
word, clue = rand_from(WORD_BANK)
if word not in playedWords:
break
playedWords.append(word)
currentWC = { 'word': word, 'clue': clue }
clueHolder = S('#clue')
clueHolder.dataset.clue = clue
clueHolder.text = '?\u20DD'
wordHolder = S('#word')
wordHolder.text = ''
def set_dataset_char(letter):
letter.dataset.char = char
frag = document.createDocumentFragment()
for char in word:
frag <= element_factory('span', { 'class_name': 'letter' + (' space' if char == ' ' else ''), 'innerHTML': '&nbsp;' }, {}, [], set_dataset_char)
wordHolder <= frag
if not lives.lives:
lives.changeLives(+1000)
clicky = window.Audio.new('https://www.fesliyanstudios.com/play-mp3/642')
async def handle_key_click(key, char):
global consecWins
if key.disabled:
return
key.disabled = True
clicky.currentTime = 0.375
clicky.play()
hit = char.upper() in currentWC['word']
key.classList.add('right' if hit else 'wrong')
if hit:
hitLetters.append(char)
letters = SS('.letter')
for letter in letters:
if not letter.dataset.char == char:
continue
letter.text = char or letter
visible_letters = [letter for letter in letters if letter.text == letter.dataset.char or letter.dataset.char == ' ']
if len(visible_letters) != len(currentWC['word']):
return
await show_message(f'''You won the round!
The word was "{currentWC["word"]}"
''', True)
consecWins += 1
if consecWins == 3:
await show_message('You won the whole game!', True)
lives.changeLives(+1000)
start()
elif lives.changeLives(-1) == 0:
await show_message(f'''You lost!
The word was "{currentWC["word"]}"''', True)
consecWins = 0
start()
def on_click_clue(e):
clue = e.target
if not clue.dataset.clue:
return
clue.text = '?\u20DD' if clue.dataset.clue == clue.text else clue.dataset.clue
S('#clue').addEventListener('click', on_click_clue)
async def wait_for(check):
while not check():
await aio.sleep(0.25)
message_is_showing = lambda: S('#recycle-bin').classList.contains('message')
def hide_message(bin, fade):
bin.classList.remove('message')
if fade:
fade.classList.remove('active')
async def show_message(text, blocking=False):
bin = S('#recycle-bin')
bin.classList.add('message')
bin.dataset.messageText = text
fade = blocking and S('#fade')
if blocking:
fade.classList.add('active')
bin.addEventListener('click', lambda click: hide_message(bin, fade))
return await wait_for(lambda: not bin.classList.contains('message'))
def on_resize(event):
global indent_rows
indent_rows = window.innerWidth > 450
for index, row in enumerate(SS('.row')):
row.attrs['style'] = f'margin-left: {index * 1}em' if indent_rows else ''
lives.updateDisplay()
window.addEventListener('resize', on_resize)
def on_keyup(event):
key = event.key
if message_is_showing():
if key in ('Enter', 'Escape'):
S('#recycle-bin').click()
return
if any(key.lower() in row for row in KEYBOARD_ROW_CHARS):
elem = document.evaluate(f"//*[@class='key' and text()='{key.upper()}']", document, None, window.XPathResult.FIRST_ORDERED_NODE_TYPE, None).singleNodeValue
if not elem or elem.disabled:
return
elem.click()
if key == '?':
S('#clue').click()
window.addEventListener('keyup', on_keyup)
class LifeCounter:
def __init__(self, maximum):
self.sys32 = S('#system32')
self.recy = S('#recycle-bin')
self.lives = maximum
self.maximum = maximum
self.updateDisplay()
def setPercentage(self, percent):
self.sys32.style.left = str(80 * percent * 0.01 + 5) + 'vw'
self.sys32.style.transform = 'scale(0) rotate(360deg)' if percent == 100 else ''
def updateDisplay(self, ):
self.sys32.dataset.text = '❤️' * self.lives
self.recy.dataset.text = 'Lives'
self.setPercentage(100 - (self.lives / self.maximum * 100))
def changeLives(self, change):
self.lives += change
if self.lives < 0:
self.lives = 0
if self.lives > self.maximum:
self.lives = self.maximum
self.updateDisplay()
return self.lives
lives = LifeCounter(MAX_LIVES)
start()
<head>
<title>Hangman</title>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<span id="fade"></span>
<span id="clue"></span>
<main>
<span id="word"></span>
<span id="keyboard"></span>
<span id="lives">
<picture id="system32">
<img src="https://icons.veryicon.com/png/Folder/Vista%20Folders%203/system32.png" />
</picture>
<picture id="recycle-bin">
<img src="http://icons.iconarchive.com/icons/iconshock/vista-general/256/trash-icon.png" />
</picture>
</span>
</main>
<script src="index.js"></script>
</body>
<html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment