Wordle
Source code
Close
<?php
/*
This is a simple wordle clone that focuses on retro browsers.
This includes:
- Works without JS
- Works without CSS
- Works without cookies
- Can work without color
Browser support
---------------
Oldest browser tested: Internet Explorer 1.5
Recommended oldest browser: Internet Explorer 2 (1.5 lacks color support)
This site will not work in NCSA Mosaic because it lacks support for forms.
Working website at:
- https://wordle.ayra.ch/ modern browsers with modern TLS support
- http://wordle.ayra.ch/ browsers without modern TLS support
- http://wordle.ayra.ch:8081/ old browsers without HTTP/1.1 support
How to host this yourself
-------------------------
1. Put this file onto your webserver
2. Create a subdirectory "lists" and make sure it's writable by PHP
3. Download ALL.TXT and SOLUTION.TXT from https://gitload.net/AyrA/Wordle-C into the lists directory
4. Open this website, this should create cache files and a key.bin file
5. You can now make the lists directory readonly
6. Make the directory inaccessible to web users
access to lists directory
-------------------------
If you do not make the directory inaccessible,
users can cheat the game. They can download the cached solution file
and then find the word according to the game id.
Alternative locations
---------------------
You can change the LIST_DIR constant
if you want to put the directory somewhere else.
*/
error_reporting(E_ALL);
//"lists" directory
define('LIST_DIR',__DIR__ . '/lists');
//Word files
define('LIST_ALL',LIST_DIR . '/ALL.TXT');
define('LIST_SOLUTION',LIST_DIR . '/SOLUTION.TXT');
//Cache files
define('CACHE_ALL',LIST_DIR . '/ALL.cache');
define('CACHE_SOLUTION',LIST_DIR . '/SOLUTION.cache');
//Encryption and signing key file
define('KEYFILE',LIST_DIR . '/key.bin');
//Lower this for small resolutions to make the cell padding smaller
define('CELLSIZE',30);
//Ditto above, but for cell margins
define('CELLSPACE',5);
//Number of guesses you have. Increasing makes the game easier
define('MAX_GUESSES',6);
//Alphabet (contains all possible characters in your words)
define('ALPHA','ABCDEFGHIJKLMNOPQRSTUVWXYZ');
//Numerical constants. Do not change.
//And if you do for whatever unjustified reason, make sure they're unique.
define('LETTER_MATCH',2);
define('LETTER_MISMATCH',1);
define('LETTER_WRONG',0);
define('COLOR_MATCH','lime');
define('COLOR_MISMATCH','yellow');
//Holds all words
$all=NULL;
//Holds possible solutions
$solutions=NULL;
//Holds the encryption key
$enc_key=NULL;
//Set charset to an old compatible one instead of UTF-8
header('Content-Type: text/html; charset=iso-8859-1');
//HTML encode
function he($x){
return htmlspecialchars($x,ENT_HTML401|ENT_COMPAT|ENT_SUBSTITUTE);
}
//Get value from array without having to test if the key or array exists first.
function av($a,$k,$d=NULL){
//Keys in PHP are strings or numbers
if(!is_string($k) && !is_numeric($k)){
return $d;
}
//Check if array and key exist
if(!is_array($a) || !isset($a[$k])){
return $d;
}
return $a[$k];
}
//Initialize script
function init(){
global $all;
global $solutions;
global $enc_key;
//Generate random key if it doesn't exists
if(!is_file(KEYFILE)){
file_put_contents(KEYFILE,$enc_key=random_bytes(32));
}
else{
$enc_key=file_get_contents(KEYFILE);
}
//Build word cache if necessary
if(!is_file(CACHE_ALL)){
$all=array_map('trim',file(LIST_ALL));
$all=array_unique(array_map('strtoupper',$all));
file_put_contents(CACHE_ALL,serialize($all));
}
else{
$all=unserialize(file_get_contents(CACHE_ALL));
}
//Build solution cache if necessary
if(!is_file(CACHE_SOLUTION)){
$solutions=array_map('trim',file(LIST_SOLUTION));
$solutions=array_unique(array_map('strtoupper',$solutions));
//Solutions must be shuffled to prevent people from guessing words via game id.
shuffle($solutions);
file_put_contents(CACHE_SOLUTION,serialize($solutions));
}
else{
$solutions=unserialize(file_get_contents(CACHE_SOLUTION));
}
}
//Make a guess
function guess($guess,$real){
$orig=$guess;
$ret=array();
$len=min(strlen($guess),strlen($real));
$guess=strtoupper($guess);
$real=strtoupper($real);
//Do correct matches first
for($i=0;$i<$len;$i++){
if($guess[$i]===$real[$i]){
$ret[$i]=LETTER_MATCH;
//Blank out letters to avoid false positives later
$guess[$i]='_';
$real[$i]='#';
}
else{
//Default to wrong letter to set array to the correct size
$ret[$i]=LETTER_WRONG;
}
}
//Do incorrect matches next
for($i=0;$i<$len;$i++){
if($ret[$i]===LETTER_WRONG){
$pos=strpos($real,$guess[$i]);
if($pos!==FALSE){
$ret[$i]=LETTER_MISMATCH;
//Blank letter to avoid false positives on duplicate letters
$real[$pos]='#';
}
}
}
return array('word'=>$orig,'matrix'=>$ret);
}
//Render a table row for a guess
function tbl($number,$guess, $usecolor=TRUE){
$w=CELLSIZE;
$ret= "<tr><td width=$w height=$w bgcolor=red><center><font color=white>" . he($number) . '</font></center></td>';
$chars=str_split($guess['word']);
$m=$guess['matrix'];
foreach($chars as $i=>$c){
$color='';
switch($m[$i]){
case LETTER_MATCH:
if($usecolor){
$color='bgcolor=' . COLOR_MATCH;
}
$type='strong';
break;
case LETTER_MISMATCH:
if($usecolor){
$color='bgcolor=' . COLOR_MISMATCH;
}
$type='u';
break;
default:
$color='';
$type='i';
break;
}
$ret.="<td width=$w height=$w $color><center><$type>" . he($c) . "</$type></center></td>";
}
return $ret . '</tr>';
}
//Filters the alphabet for used/unused letters
function filterAlpha($guesses){
$alpha=str_split(ALPHA);
foreach($alpha as $char){
$ret[$char]=FALSE;
foreach($guesses as $guess){
if(strpos($guess['word'],$char)!==FALSE){
$ret[$char]=TRUE;
}
}
}
return $ret;
}
//Start a new game from the sent game id
function newGame(){
if(isValidId()){
return av($_POST,'id')|0;
}
return NULL;
}
//Signs data
function hmac($x){
global $enc_key;
return base64_encode(hash_hmac('sha1',$x,$enc_key,TRUE));
}
//Decrypts data
function decrypt($state){
global $enc_key;
//0: hmac
//1: iv
//2: data
$parts=explode(':',$state);
if(count($parts)!==3 || hmac($parts[1] . ':' . $parts[2])!==$parts[0]){
return NULL;
}
$dec=openssl_decrypt(base64_decode($parts[2]),'aes-256-cbc',$enc_key,OPENSSL_RAW_DATA,base64_decode($parts[1]));
return $dec===FALSE?NULL:json_decode($dec,TRUE);
}
//Encrypts data
function encrypt($state){
global $enc_key;
$iv=random_bytes(16);
$result=openssl_encrypt(json_encode($state),'aes-256-cbc',$enc_key,OPENSSL_RAW_DATA,$iv);
$sign=base64_encode($iv) . ':' . base64_encode($result);
//0: hmac
//1: iv
//2: data
return hmac($sign) . ':' . $sign;
}
//Gets a game state object for new or existing games
function getGameState(){
global $solutions;
$game=av($_POST,'state');
if($game!==NULL){
//Try to decrypt game
$game=decrypt($game);
}
//If game not set (or decryption failed) start a new game
if($game===NULL){
$game=array(
'id'=>newGame(),
'guesses'=>array(),
'solved'=>FALSE,
'hints'=>FALSE,
'color'=>TRUE
);
$game['word']=$solutions[$game['id']-1];
}
else{
//Set defaults to allow upgrade of old game states
$game['hints']=av($game,'hints')===TRUE;
$game['color']=av($game,'color')===TRUE;
}
return $game;
}
//Checks if a word is in the list
function hasWord($w){
global $all;
return in_array(strtoupper($w),$all);
}
//Checks if the given word is already guessed
function isGuessed($w,$g){
foreach($g as $guess){
if($guess['word']===$w){
return TRUE;
}
}
return FALSE;
}
//Checks if a game is running
function isGameRunning(){
return strtoupper(av($_SERVER,'REQUEST_METHOD','GET'))==='POST';
}
//Checks if the submitted game id is valid
function isValidId(){
global $solutions;
$id=av($_POST,'id');
return is_numeric($id) && ($id|0)>0 && ($id|0)<=count($solutions);
}
//Shows an error message
function showErr($err){
if($err){
return '<font color=red><strong>' . he($err) . '</strong></font><br />';
}
return '';
}
//This calculates all remaining possible solutions for the given array of guesses
function getPossibleWords($guesses){
global $solutions;
$ret=$solutions;
$charsAny='';
sort($ret);
foreach($guesses as $guess){
$regexMatch='';
$charsFail='#';
$matched=array();
$w=$guess['word'];
$g=$guess['matrix'];
//First, retain all definitive clues
foreach($g as $i=>$num){
if($num===LETTER_MATCH){
$regexMatch.=preg_quote($w[$i],'#');
$matched[]=$w[$i];
}
else if($num===LETTER_MISMATCH){
$regexMatch.='[^' . preg_quote($w[$i],'#') . ']';
$matched[]=$w[$i];
$charsAny.=$w[$i];
}
else{
$regexMatch.='.';
}
}
$regexMatch='#^' . $regexMatch . '$#';
//Build trim mask
//We do this after matching to avoid problems with duplicate characters in words
foreach(str_split($w) as $c){
if(!in_array($c,$matched)){
$charsFail.=$c;
}
}
/*You can enable the lines below by adding "/" at the start of this one
//This allows you to see what exactly is filtered
//by looking at the page source in your browser.
echo "<!--
Regex: $regexMatch
Must not exist: $charsFail
Must exist: $charsAny
-->";
//*/
$temp=array();
foreach($ret as $word){
if(
//Discard guesses we've made already
$word!==$w &&
//Regex must match
preg_match($regexMatch,$word) &&
//Characters known to not be in the word must not appear anywhere
strpbrk($word,$charsFail)===FALSE &&
//Chars in wrong positions must appear somewhere in the word
//The regex already ensures they're NOT in the yellow position
hasAllChars($word,$charsAny)){
$temp[]=$word;
}
}
$ret=$temp;
}
return $ret;
}
//Tests if all given characters appear in the given word
function hasAllChars($word,$chars){
if(strlen($chars)===0){
return TRUE;
}
foreach(str_split($chars) as $c){
if(strpos($word,$c)===FALSE){
return FALSE;
}
}
return TRUE;
}
init();
$err=NULL;
$game=NULL;
//Check if game is running before doing any game related checks
if(isGameRunning()){
$mode=av($_POST,'mode');
//New game
if($mode==='new'){
//There is no difference between a random game and a chosen id.
//The random game form simply has a hidden field with a random id in it
//This way we can process the forms in the same way
if(isValidId()){
$game=getGameState();
}
else{
$err='Invalid game id';
}
}
//User makes a guess or lets the game make a guess
elseif($mode==='guess'){
$game=getGameState();
if(count($game['guesses'])<MAX_GUESSES){
$guess=av($_POST,'guess');
if($guess!==NULL){
$guess=strtoupper($guess);
if(hasWord($guess)){
if(!isGuessed($guess,$game['guesses'])){
$game['guesses'][]=guess($guess,$game['word']);
}
else{
$err='Word already guessed';
}
}else{
$err='Word not found in list';
}
}
}
}
//User wants to solve
elseif($mode==='solve'){
$game=getGameState();
$game['solved']=TRUE;
$game['guesses'][]=guess($game['word'],$game['word']);
}
//User wants to change hint mode
elseif($mode==='hint'){
$game=getGameState();
$game['hints']=!$game['hints'];
}
//User wants to change color mode
elseif($mode==='color'){
$game=getGameState();
$game['color']=!$game['color'];
}
//Invalid form action
else{
$game=getGameState();
$err='Invalid game action';
}
if($game){
//Create helper values
$alpha=filterAlpha($game['guesses']);
$hasGuess=count($game['guesses'])>0;
}
else{
//Use fake values
$alpha=filterAlpha(array());
$hasGuess=FALSE;
}
}
//Self referential URL (without query string)
$url=av($_SERVER,'PHP_SELF','index.php');
?><!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html lang="en">
<head><title>Wordle</title><meta name="viewport" content="width=device-width, initial-scale=1" /></head>
<body vlink=blue link=blue>
<h1>Wordle</h1>
<?php if($game){ ?>
<font face="Courier New,Courier,System,Monospace">
<table border=2>
<tr>
<th><h1><center>Guesses</center></h1></th>
<th><h1><center>Alphabet</center></h1></th>
</tr>
<tr>
<td>
<?php
$ended=FALSE;
if(!$hasGuess){
echo 'Waiting for first guess';
}
else{
echo '<table border=2 cellspacing=' . CELLSPACE . '>';
foreach($game['guesses'] as $i=>$g){
echo tbl($i+1,$g,$game['color']);
$won=$g['word']===$game['word'];
if(!$ended && $i+1===MAX_GUESSES || $won){
$ended=TRUE;
if($game['solved']){
$resultcolor=COLOR_MISMATCH;
$result='gave up';
}
else{
$resultcolor=$won?COLOR_MATCH:'red';
$result=$won?'win':'lose';
}
echo "<tr><td colspan=6 bgcolor=$resultcolor>Game ended. You $result</td></tr>";
}
}
echo '</table>';
}
?>
</td>
<td>
Used letters are shown in color<br />
and <u>underlined</u>
<br />
<br />
<center>
<table border=2 cellspacing=<?=CELLSPACE;?>>
<?php
$w=CELLSIZE;
foreach(str_split(ALPHA,7) as $chunk){
echo '<tr>';
foreach(str_split($chunk) as $char){
$c=$game['color'] && $alpha[$char]?COLOR_MATCH:'white';
$type=$alpha[$char]?'u':'span';
echo "<td width=$w height=$w bgcolor=$c><center><$type>$char</$type></center></td>";
}
echo '</tr>';
}
?>
</table>
</center>
<br />
<?php if($ended){ ?>
Game ended.<br />
The word was <strong><?=he($game['word']);?></strong><br />
<a href="<?=he($url);?>">New Game</a>
<?php }else{ $remaining=getPossibleWords($game['guesses']); ?>
<center>
<form method="post">
<input type="hidden" name="mode" value="guess" />
<input type="hidden" name="state" value="<?=encrypt($game);?>" />
<input type="text" name="guess" placeholder="Guess"
required <?php if($hasGuess){ echo 'autofocus'; } ?> />
<input type="submit" value="Guess" />
</form><br />
</center>
<br />
<form method="post">
<input type="hidden" name="mode" value="guess" />
<input type="hidden" name="state" value="<?=encrypt($game);?>" />
<input type="hidden" name="guess" value="<?=he($remaining[mt_rand(0,count($remaining)-1)]);?>" />
<input type="submit" value="Educated guess" />
</form>
<br />
<br />
<form method="post">
<input type="hidden" name="mode" value="hint" />
<input type="hidden" name="state" value="<?=encrypt($game);?>" />
Show word hints: <strong><?=he($game['hints']?'Yes':'No');?></strong>
<input type="submit" value="<?=he($game['hints']?'disable':'enable');?>" />
</form><br />
<form method="post">
<input type="hidden" name="mode" value="color" />
<input type="hidden" name="state" value="<?=encrypt($game);?>" />
Color: <strong><?=he($game['color']?'Yes':'No');?></strong>
<input type="submit" value="<?=he($game['color']?'disable':'enable');?>" />
</form><br />
<?php } ?>
<?=showErr($err);?>
<h3>Stats:</h3>
Game Id: #<?=he($game['id']);?><br />
Guesses: <?=he(count($game['guesses']). '/' . MAX_GUESSES);?><br />
<?php if(!$ended){ ?>
<form method="post">
<input type="hidden" name="state" value="<?=encrypt($game);?>" />
<input type="hidden" name="mode" value="solve" />
<input type="submit" value="Give up and solve" />
</form>
<?php } ?>
</td>
</tr>
</table>
<?php if(!$ended){ ?>
<p><?php
//This is now done sooner to implement the random guess form
//$remaining=getPossibleWords($game['guesses']);
echo 'Number of possible words remaining: ' . count($remaining) . '<br />';
echo 'Words:<br />';
if($game['hints']){
if(count($remaining)>200){
echo '<i>Word hints enabled. Words will be shown when 200 or less are remaining</i>';
}
else{
echo he(implode(' ',$remaining));
}
}
else{
echo '<i>Word hints disabled. You can enable them to get a list of all words that are possible according to your guesses.</i>';
}
?></p>
</font>
<a href="<?=he($url);?>">New game</a>
<?php } ?>
<?php } else { ?>
<?=showErr($err);?>
<?php
$viewmode=av($_GET,'view');
if($viewmode==='list'){
$temp=$solutions; //PHP clones arrays instead of referencing them when you assign them somewhere
sort($temp); //We cloned the array because sort works in-place
$chunks=array_chunk($temp,8);
echo '<h2>Word list</h2>';
echo '<p><a href="' . he($url) . '">Close</a></p>';
echo '<p>Known words: ' . count($all) . '<br />Possible solutions: ' . count($solutions) . '</p>';
echo '<table border=2 cellpadding=2 cellspacing=2>';
foreach($chunks as $chunk){
echo '<tr>';
foreach($chunk as $word){
echo '<td>' . he($word) . '</td>';
}
echo '</tr>';
}
echo '</table>';
echo '<i>The words do not map directly to game ids</i>';
}elseif($viewmode==='source'){
echo '<h2>Source code</h2>';
echo '<p><a href="' . he($url) . '">Close</a></p>';
highlight_file(__FILE__);
}elseif($viewmode==='help'){ ?>
<p><a href="<?=he($url);?>">Close</a></p>
<h2>About this website</h2>
<p>
This website is a wordle clone optimized for old machines.
There is no JS or CSS involved at all.
It uses only legacy HTML elements
and loads fairly fast even on slow dialup.
No cookies are required either.<br />
If you want an even less advanced version,
you can check out
<a href="https://github.com/AyrA/Wordle-C" target="_blank">my version in C</a>
that's optimized for text terminals and runs under DOS.
</p>
<p>
The oldest browser this has been tested with is Internet Explorer 1.5.<br />
Note that this browser is so old it lacks color support.
If you want color, use at least version 2.0.<br />
If your browser has broken color support you can disable color during a game.
</p>
<h2>How to play wordle</h2>
<p>
You have to guess the secret 5 character word within 6 attempts.
After you make a guess, the background of letters in your word are colored in either
white, green, or yellow.<br />
A white letter does not appear anywhere in the word.
A green letter appears in the correct position.
A yellow letter is in the word but not the correct position.<br />
Double letters are handled correctly.
If the hidden word is <strong>CADET</strong> and you guess
<strong>TESTS</strong> the first T will be yellow and the second T will be white.
</p>
<p>
In addition to colors,
letters are styled to ensure they work on browsers with defective or missing color support.<br />
<u>underline</u> means the same as green.<br />
<strong>bold</strong> means the same as yellow.<br />
<i>italic</i> means the same as white.
</p>
<p>
No attempt is consumed if you guess a word that is not in the list.
</p>
<h2>Tips</h2>
<p>
The first 2 or 3 guesses should be used to eliminate as many letters as possible
unless you have very strong hints for the solution after the first guess already.<br />
How many words are possible is shown below the game table.
</p>
<h2>Game view explanation</h2>
<p>
The game field looks like this:
</p>
<table border=2>
<tr>
<td rowspan=3>
This shows the guesses with colored hints
</td>
<td>
This shows the alphabet with used letters in grey
and <u>underlined</u>
</td>
</tr>
<tr>
<td>
In this section you play the game.<br />
Here you make manual and automatic guesses.<br />
You can also enable and disable hints here.
</td>
</tr>
<tr>
<td>
This shows your game id and the number of guesses.
</td>
</tr>
</table>
<p>
Word hints are disabled by default.
You can enable and disable them to get a list of possible solutions
below the game.
A game always starts with word hints disabled.
</p>
<h2>Educated guessing</h2>
<p>
The game keeps a list of all words possible according to your guesses.
The "educated guessing" button
picks one of the possible words as your next guess.
</p>
<h2>Word hints</h2>
<p>
The word hint is fairly smart.
It takes all hints (white, yellow, green) into account.<br />
It also handles yellow hints correctly
by filtering words that do contain the letter but not at the given position.<br />
The only hint so far that it disregards is when you guess a word like
<strong>TASTY</strong> and the result shows that only one T exists in the solution.
</p>
<?php } else { ?>
<p>
Welcome to wordle.
Please pick a game mode below.
</p>
<form method="post">
<input type="hidden" name="mode" value="new" />
<input type="hidden" name="id" value="<?=mt_rand(1,count($solutions));?>" /><br />
<input type="submit" value="Play random game" />
</form><br />
<form method="post">
Play the given id again:
<input type="hidden" name="mode" value="new" />
<input type="text" name="id" size=6 placeholder="Game Id" />
<input type="submit" value="Play" /><br />
</form><br />
<p><a href="<?=he($url);?>?view=help">Help</a></p>
<p><a href="<?=he($url);?>?view=list">View list of possible solutions</a></p>
<p><a href="<?=he($url);?>?view=source">View source code</a></p>
<?php } ?>
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<?php } ?>
</body>
</html>