Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
betaveros committed Feb 4, 2018
0 parents commit 502cab8
Show file tree
Hide file tree
Showing 9 changed files with 1,941 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.mypy_cache
castlefall-config.ts
dist
wordlists
22 changes: 22 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Copyright (c) 2018 Brian Chen

Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
26 changes: 26 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Castlefall
==========

Castlefall ([Castle of the Devil](https://boardgamegeek.com/boardgame/25951/castle-devil) + [Spyfall](https://boardgamegeek.com/boardgame/166384/spyfall)) is a party word game for usually six to ten people, preferably an even number. I'm not totally sure who invented it.

Players should load the site; they will each receive a word list, which is the same across all players in the round, and one word from the list. The players are in two equally-sized teams (or almost-equally-sized if there are an odd number of players); each player on a team has the same word, and the two teams have different words, but players don't know who else is on their team.

Players begin discussing, with the goal of trying to figure out who else is on their team or what the other team's word is, until somebody declares victory (usually signified by clapping loudly, since earlier declarations take precedence). There are two ways to declare victory:

1. Choose N players (including yourself) and claim that they are on the same team. N is usually 3 for 6- to 8-player games and 4 for 9- and 10-player games, although there's some player choice here. Nobody else can declare victory with this method after you have done so. Start a one-minute timer, and continue discussing; if nobody else declares victory using method 2 after one minute has elapsed, the round ends, and you (and your team) win iff your declaration was correct.

2. Guess the other team's word. The round immediately ends; you and your team win iff your guess was correct.

Note that you always win or lose with your team (the set of people who had the same word as you did).

Strategy
========

The usual strategy is to give clues about your word that are recognizable to people on your team who are trying to fit that word with the clue, but not so obvious that your opponents will be able to figure out your word from the 17-or-so other options. Castlefall is all about striking this balance. Note that you can react to clues that you don't actually recognize to trick them into thinking you're on their team. You can also give clues about other words, perhaps words that you suspect are the other team's, and see if anybody else reacts to try to guess the other team's word. This runs the risk, however, of tricking somebody into thinking that that other word is actually your word and declaring victory on it; that somebody may or may not be on your team.

Setup/Development
===========

This is pretty hacky. The client-side code is TypeScript, so you should transpile it with TypeScript and then run it through Browserify and UglifyJS. Right now I just have a simple build script; I haven't gotten to setting up a Node.js task running manager thing yet. The server is Python; you'll need Twisted and Autobahn. Set up the Python websockets server running somewhere (run with `prod` as an argument to actually serve to the world), transpile the JS with the config file pointed to the server, and serve the HTML and transpiled JS page.

Wordlists, simple newline-separated text files, go in the `wordlists/` directory; as an example I've put [EFF's short diceware wordlist](https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases) ([CC-BY 3.0](http://creativecommons.org/licenses/by/3.0/us/)) there. There are more suitable word lists out there, but the copyrightability of word lists is an interesting murky area of copyright law that I'd rather steer clear of, just in case.
4 changes: 4 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
mkdir -p dist
tsc castlefall.ts --outDir dist && browserify dist/castlefall.js -o dist/castlefall.bundle.js && uglifyjs dist/castlefall.bundle.js -o dist/castlefall.min.js --source-map
cp index.html dist
1 change: 1 addition & 0 deletions castlefall-config.example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const websocketURL = "ws://localhost:8372/";
215 changes: 215 additions & 0 deletions castlefall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { websocketURL } from "./castlefall-config";

const clientVersion = "v0.2";

function makeKicker(ws: WebSocket, name: string): Element {
var button = document.createElement('button');
button.textContent = 'kick';
button.addEventListener('click', function() {
ws.send(JSON.stringify({
kick: name,
}));
});
return button;
}
function clear(node: Element): void {
while (node.hasChildNodes()) {
node.removeChild(node.lastChild);
}
}
function setPlayers(ws: WebSocket, list: string[]): void {
var node = document.getElementById('players');
clear(node);
for (var i0 = 0; i0 < list.length; i0 += 4) {
var tr = document.createElement('tr');
var iend = Math.min(i0 + 4, list.length);
for (var i = i0; i < iend; i++) {
var name = list[i];
var td = document.createElement('td');
td.appendChild(makeKicker(ws, name));
td.appendChild(document.createTextNode(name));
tr.appendChild(td);
}
node.appendChild(tr);
}
}
function setContents(node: Element, list: string[]): void {
clear(node);
list.forEach(function (text) {
var div = document.createElement('div');
div.textContent = text;
node.appendChild(div);
});
}
function makeContainer(list: string[]): Element {
// make a div that contains the words in the list, which will be
// CSS-formatted to be in 2 to 4 columns
var container = document.createElement('div');
container.className = 'container';
setContents(container, list);
return container;
}
function makeh3(text: string): Element {
var h3 = document.createElement('h3');
h3.textContent = text;
return h3;
}
var lastRound: number = 0;
function createRound(round: number, players: string[], words: string[], word: string|null) {
lastRound = round;
var div = document.createElement('div');
div.className = 'round';
var roundh3 = makeh3('Round ' + round);
div.appendChild(roundh3);

var bodydiv = document.createElement('div');
bodydiv.appendChild(makeContainer(players));
bodydiv.appendChild(makeh3('Words'));
bodydiv.appendChild(makeContainer(words));
var worddiv = document.createElement('div');
worddiv.className = 'container';
if (word) {
var wordelt = document.createElement('strong');
wordelt.textContent = word;
var button = document.createElement('button');
button.textContent = "show/hide";
button.addEventListener('click', function () {
if (wordelt.textContent === word) {
wordelt.textContent = '';
} else {
wordelt.textContent = word;
}
});
worddiv.appendChild(button);
worddiv.appendChild(document.createTextNode(' Your word is: '));
worddiv.appendChild(wordelt);
} else {
worddiv.appendChild(document.createTextNode('You are spectating'));
}
bodydiv.appendChild(worddiv);
div.appendChild(bodydiv);

roundh3.addEventListener('click', function () {
if (!bodydiv.style.display || bodydiv.style.display === 'block') {
bodydiv.style.display = 'none';
} else {
bodydiv.style.display = 'block';
}
});

var rounds = document.getElementById('rounds');
if (rounds.firstChild) {
rounds.insertBefore(div, rounds.firstChild);
} else {
rounds.appendChild(div);
}

}
var startMillis: number = new Date().getTime();
function updateTime() {
var left = 60 - (new Date().getTime() - startMillis) / 1000;
var time = document.getElementById('time');
var timer = document.getElementById('timer');
if (left > 0) {
document.getElementById('time').textContent = left.toFixed(3);
setTimeout(updateTime, 37);
const fraction = left / 60;
const percent = (100 * fraction) + "%";
const hue = fraction * 120;
timer.style.backgroundRepeat = 'repeat-y, repeat-x';
timer.style.backgroundImage = ('linear-gradient(to right, hsla(' + hue +
', 100%, 50%, 0.3) 0, hsla(' + hue +
', 100%, 50%, 0.3) ' + percent +
', transparent ' + percent +
'), linear-gradient(to bottom,#668 0,#224 100%)');
} else {
document.getElementById('time').textContent = '0';
timer.style.backgroundRepeat = '';
timer.style.backgroundImage = '';
}
}
function pad2(i: number): string {
return (i < 10 ? "0" : "") + i;
}
function displayMessage(msg: string): void {
const now = new Date();
const h = pad2(now.getHours());
const m = pad2(now.getMinutes());
const s = pad2(now.getSeconds());
const div = document.createElement('div');
div.textContent = h + ":" + m + ":" + s + ": " + msg;
document.getElementById("msg").appendChild(div);
}
function getName(): string {
let name = prompt('Enter your name');
while (!name) {
name = prompt('Enter your name!');
}
return name;
}
window.addEventListener("load", function() {
document.getElementById('cliv').textContent = clientVersion;
const name = getName();
document.getElementById('name').textContent = name;
const room = window.location.hash || '#lobby';
document.getElementById('room').textContent = room;
document.getElementById('roomhelp').addEventListener('click', function() {
alert("Append #roomname to the URL and reload to go to a new room. (It's hacky. PRs welcome.)");
});
const ws = new WebSocket(websocketURL);
ws.onopen = function () {
ws.send(JSON.stringify({
name: name,
room: room,
}));
};
ws.onclose = function (event) {
displayMessage("Connection closed: " + JSON.stringify(event));
};
ws.onerror = function (event) {
displayMessage("Connection error: " + JSON.stringify(event));
};
ws.onmessage = function (event) {
var data = JSON.parse(event.data);
if (data.version) {
document.getElementById("serv").textContent = data.version;
}
if (data.players) {
setPlayers(ws, data.players);
}
if (data.round) {
createRound(data.round, data.playersinround, data.words, data.word);
}
if (data.msg) {
document.getElementById("msg").textContent = data.msg;
}
if (data.wordlists) {
var node = document.getElementById('wordlists');
while (node.hasChildNodes()) {
node.removeChild(node.lastChild);
}
data.wordlists.forEach(function (wordlist) {
var option = document.createElement('option');
option.value = wordlist[0];
option.textContent = wordlist[0] + " (" + wordlist[1] + " words)";
node.appendChild(option);
});
}
};
document.getElementById('newround').addEventListener('click', function() {
let wordlistNode = document.getElementById('wordlists') as HTMLSelectElement;
let wordcountNode = document.getElementById('wordcount') as HTMLInputElement;
const wordlist = wordlistNode.options[wordlistNode.selectedIndex].value;
ws.send(JSON.stringify({
start: {
round: lastRound,
wordlist: wordlist,
wordcount: wordcountNode.value,
},
}));
});
document.getElementById('timer').addEventListener('click', function() {
startMillis = new Date().getTime();
updateTime();
});
});
118 changes: 118 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
#wrap { max-width: 42em; margin: 0 auto; }
body { font-family: sans-serif; margin: 0; padding: 0.5em; }
#time { font-family: monospace; font-size: 150%; }
#msg {
margin-top: 1em;
font-size: 150%; color: #c00;
}
h1 { padding: 0; margin: 0.2em 0 0.5em; }
h3 { margin: 0 0 0.5em 0; border-bottom: 1px solid black; padding: 0.5em 0.5em 0; }
tr, td { margin: 0; padding: 0 0.5em 0 0; }
.container {
padding: 0.5em;
}
.container div {
display: inline-block;
min-width: 8.5em;
padding: 0 1em 0.5em 0;
float: left;
}
.container::after {
display: block;
content: " ";
clear: both;
}
#worddiv { padding: 2em; }
.round {
border: 1px solid #666;
border-radius: 1em;
margin-bottom: 1em;
}
input#wordcount, select#wordlists {
font-size: 100%;
text-align: center;
border-radius: 9990px;
border: 1px solid #bbd;
background-image: linear-gradient(to bottom,#f8f8ff 0,#f0f0f8 100%);
box-shadow: inset 0 1px 1px rgba(0,0,0,.3);
}
input#wordcount:active, input#wordcount:focus {
background-color: #dde;
background-image: linear-gradient(to bottom,#dde 0,#eef 100%);
}
select#wordlists {
padding: 0 0.5em;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
}
button {
display: inline-block;
padding: 6px 12px;
font-weight: 400;
line-height: 1.42857143;
text-align: center;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: 9999px;
color: #fff;
background-color: #224;
background-image: linear-gradient(to bottom,#668 0,#224 100%);
background-repeat: repeat-x;
border-color: #224;
text-shadow: 0 -1px 0 rgba(0,0,0,.2);
box-shadow: inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);
margin-right: 0.5em;
}
#roomhelp {
font-size: 50%;
padding: 0 3px;
}
#timer {
width: 100%;
}
button:hover {
background-position: 0 -15px;
}
button:active {
background-color: #224;
background-image: none;
box-shadow: inset 0 3px 5px rgba(0,0,0,.125);
}
button:hover:active {
background-color: #113;
border-color: #113;
}
button#newround {
font-size: 18px;
}
</style>
<script type="text/javascript" src="castlefall.min.js"></script>
</head>
<body>
<div id="wrap">
<h1>Castlefall</h1>
<div>You are <span id="name"></span> in <span id="room"></span><sup><button id="roomhelp">?</button></sup> (client <span id="cliv"></span> / server <span id="serv"></span> / <a href="https://github.com/betaveros/castlefall">PRs welcome</a>)</div>
<div><button id="timer"><span id="progress"></span>Timer: <strong id="time">60</strong></button></div>
<div id="msg"></div>
<h2>Players</h2>
<table id="players"></table>
<button id="newround"><strong>+</strong> New Round</button>
with
<input type="text" id="wordcount" value="18" size="2">
<label for="wordcount">words from</label>
<select id="wordlists"></select>
<h2>Rounds</h2>
<div id="rounds"></div>
</div>
</body>
</html>
Loading

0 comments on commit 502cab8

Please sign in to comment.