WORDLE

Source code

Close

<?php
/*
This is a simple wordle clone that focuses on NCSA Mosaic.
This browser has sever limitations:
- No JS
- No CSS
- No cookies
- No color
- No table or other formatting utilities
- No forms or input fields

Browser support
---------------
Tested with NCSA Mosaic 1.0 on Windows

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

Note: This file needs colored gif images.
It's probably best if you write a small PHP script that does this for you.
Note: You cannot use the PHP file directly unless you rewrite the URL.
Reason is that Mosaic detects the file type by the file extension only
because the "Content-Type" header did not exist yet back then.

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');

//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);

//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. Not that Mosaic would care.
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 image row for a guess
function imgrow($number,$guess){
$fg='W';
$bg='R';
$chars=str_split($guess['word']);
$m=$guess['matrix'];
foreach($chars as $i=>$c){
switch($m[$i]){
case LETTER_MATCH:
$fg.='0';
$bg.='G';
break;
case LETTER_MISMATCH:
$fg.='0';
$bg.='Y';
break;
default:
$fg.='0';
$bg.='W';
break;
}
}
return img($number . $guess['word'],$fg,$bg) . '<br />';
}

//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(){
global $solutions;
$id=av($_GET,'id');
if(isValidId($id)){
return $id|0;
}
return mt_rand(1,count($solutions));
}

//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: data
$parts=explode(':',$state);
if(count($parts)!==2 || hmac($parts[1])!==$parts[0]){
return NULL;
}
$iv=substr(sha1($enc_key),0,16);
$dec=openssl_decrypt(base64_decode($parts[1]),'aes-256-cbc',$enc_key,OPENSSL_RAW_DATA,$iv);
return $dec===FALSE?NULL:json_decode($dec,TRUE);
}

//Encrypts data
function encrypt($state){
global $enc_key;
$iv=substr(sha1($enc_key),0,16);
$result=openssl_encrypt(json_encode($state),'aes-256-cbc',$enc_key,OPENSSL_RAW_DATA,$iv);
$sign=base64_encode($result);
//0: hmac
//1: data
return hmac($sign) . ':' . $sign;
}

//Gets an empty game
function getBlankGame($id){
global $solutions;
if(isValidId($id)){
return array(
'id'=>$id,
'word'=>$solutions[$id-1],
'guesses'=>array(),
'solved'=>FALSE,
'hints'=>FALSE,
'guess'=>''
);
}
die("Invalid id: $id");
return NULL;
}

//Gets a game state object for an existing game
function getGameState(){
global $solutions;
$game=av($_GET,'state');
if($game===NULL){
die('No game is running');
}
if($game!==NULL){
//Try to decrypt game
$game=decrypt($game);
}
//If game not set (or decryption failed) start a new game
if($game===NULL){
die('FAILED TO DECRYPT: ' . av($_GET,'state'));
$game=array(
'id'=>newGame(),
'guesses'=>array(),
'solved'=>FALSE,
'hints'=>FALSE,
'guess'=>''
);
$game['word']=$solutions[$game['id']-1];
}
else{
//Set defaults to allow upgrade of old game states
$game['hints']=av($game,'hints')===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 !empty($_GET['state']);
}

//Checks if the submitted game id is valid
function isValidId($id){
global $solutions;
return is_numeric($id) && ($id|0)>0 && ($id|0)<=count($solutions);
}

//Shows an error message
function showErr($err){
if($err){
return '<font color=red><b>' . he($err) . '</b></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;
}

//Creates an img tag for the given letter(s) and color(s)
function img($x,$fg='0',$bg='W'){
$fg=str_pad('',strlen($x),$fg);
$bg=str_pad('',strlen($x),$bg);

$url="/letter-$x.gif?c=$fg&b=$bg";
if(getenv('HTTP_USER_AGENT')){
$url=he($url);
}
return "<img src=\"$url\" alt=\"$x\" />";
}

init();

$err=NULL;
$game=NULL;

//Check if game is running before doing any game related checks
if(isGameRunning()){
$game=getGameState();

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','mosaic.php');
?><!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html lang="en">
<head>
<title>Wordle for NCSA Mosaic</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>

<p><?=img('WORDLE'); ?></p>

<?php if($game){ ?>
<h2>Guessed words:</h2>
<?php
$ended=FALSE;
if(!$hasGuess){
echo '<p>Waiting for first guess</p>';
}
else{
foreach($game['guesses'] as $i=>$g){
echo imgrow($i+1,$g) . '<br />';
$won=$g['word']===$game['word'];
if(!$ended && $i+1===MAX_GUESSES || $won){
$ended=TRUE;
if($game['solved']){
$result='gave up';
}
else{
$result=$won?'win':'lose';
}
echo "<p><b>Game ended. You $result</b></p>";
}
}
}
?>

<?php
if(!$ended){
echo '<h2>Alphabet:</h2>' . PHP_EOL;
//Temporarily remember the guess state
$guesses=$game['guess'];

$links='';
foreach(str_split(ALPHA,7) as $chunk){
$colormap='';
foreach(str_split($chunk) as $char){
$colormap .= $alpha[$char]?'G':'W';

$game['guess']=$guesses . $char;
$links .= '<a href="' .$url . '?state=' . urlencode(encrypt($game)) . '">' . $char . '</a> | ';
}
echo img($chunk,'0',$colormap) . '<br />';
}
$game['guess']=$guesses;
echo $links;
?>
<br />
<i>Used letters are green</i><br />
<br />
Word so far: <b><?=$guesses;?></b>
<?php
$temp=$game['guesses'];
$g=$game['guess'];
$game['guess']='';
echo ' | <a href="'.$url.'?state=' . urlencode(encrypt($game)) . '">(Clear)</a> | ';
if(strlen($guesses)===5){
if(hasWord($g)){
$game['guesses'][]=guess($g,$game['word']);
echo ' <a href="'.$url.'?state=' . urlencode(encrypt($game)) . '"><b>Guess this word now</b></a>';
}
else{
echo ' <i>Invalid word. Please clear</i>';
}
}
$game['guesses']=$temp;
$game['guess']=$g;
}
?><br />
<br />

<?php if($ended){ ?>
<p>
<h2>Game ended</h2>
The word was <b><?=he($game['word']);?></b><br />
<a href="<?=he($url);?>">New Game</a>
</p>
<?php
}else{
$remaining=getPossibleWords($game['guesses']);

//"Educated guess" feature
$word=$remaining[mt_rand(0,count($remaining)-1)];
//Temporarly change game values to encrypt a new version of it
$temp1=$game['guesses'];
$temp2=$game['guess'];
$game['guesses'][]=guess($word,$game['word']);
$game['guess']='';
echo '<p><a href="' . $url . '?state=' . urlencode(encrypt($game)) . '">Make educated guess</a><br></p>';
$game['guesses']=$temp1;
$game['guess']=$temp2;

//Hint system
$game['hints']=!$game['hints'];
$text=$game['hints']?'Show':'Hide';
echo '<br><a href="' . $url . '?state=' . urlencode(encrypt($game)) . '">' . $text . ' word hints</a><br>';
$game['hints']=!$game['hints']; //Revert changes again
} ?>
<?=showErr($err);?>
<h2>Stats:</h2>
Game Id: #<?=he($game['id']);?><br />
Guesses: <?=he(count($game['guesses']). '/' . MAX_GUESSES);?><br />
<?php if(!$ended){ ?>
<?php } ?>

<?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{
$g=$game['guess'];
$game['guess']='';
foreach($remaining as $word){
$gg=$game['guesses'];
$game['guesses'][]=guess($word,$game['word']);
echo ' <a href="'. $url . '?state=' . urlencode(encrypt($game)) . '">' . $word . '</a> | ' . PHP_EOL;
$game['guesses']=$gg;
}
$game['guess']=$g;
}
}
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>
<br /><br />
<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,12);
echo '<h2>Word list</h2>';
echo '<p><a href="' . he($url) . '">Close</a><br /></p>';
echo '<p>Known words: ' . count($all) . '<br />Possible solutions: ' . count($solutions) . '</p><pre>';
foreach($chunks as $chunk){
echo he(implode(' ',$chunk)) . '<br />';
}
echo '</pre><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><br /></p>';
echo nl2br(str_replace('<','<',file_get_contents(__FILE__)));
}elseif($viewmode==='help'){ ?>
<p><a href="<?=he($url);?>">Close</a></p>

<h1>About this website</h1>
<p>
This website is a wordle clone optimized for NCSA Mosaic, the first browser to ever exist.
This means:
</p>
<ul>
<li>No JS</li>
<li>No CSS</li>
<li>No Cookies</li>
<li>No Forms (yes, really)</li>
<li>No Tables</li>
<li>No Color</li>
</ul><br />
<p>
This means there is zero interactivity and no ability to create basic layouts.
It's basically a glorified text viewer.
It's only in the later versions that forms were introduced.
</p>
<p>
If you want a slightly more modern version but drop NCSA support,
<a href="/">click here</a>
</p>
<p>
The question of course is now:
<b>How to implement a game in a browser not meant to play games?</b><br />
The answer to this is: Url parameters and images.
Yes, mosaic does support gif and jpeg images.
This gives us the ability to implement<br /><?=img('COLOR','000W0','RYGBW');?><br />
Image support is kinda primitive and slow.
The browser only loads a gif if the URL path ends in the .gif extension.
To solve that problem, a bit of scripting on the server was used to
trick it into executing a PHP file when a certain image was requested.
</p>
<p>
The image generator makes simple squares with a single letter inside,
and combines them into a single horizontal strip.
Mosaic is slow when it comes to image loading,
so letters shown on the same line are combined into a single image.
This however destroys the ability to use the images as links for text input.
To combat this, a list of clickable letters is provided below the alphabet itself.
</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 <b>CADET</b> and you guess
<b>TESTS</b> the first T will be yellow and the second T will be 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>
At the top are the guesses you've already made.
Below is the alphabet you can use to make guesses.
You can click on letters to add them to the next guess.
Once you pick 5 letters you get a link to commit your guess.
</p>
<p>
Word hints are disabled by default.
You can enable and disable them to get a list of possible solutions
below the game.
Words in the hints can be clicked on to use
a word as the next guess without having to click individual letters.
A game always starts with word hints disabled.<br />
</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
<b>TASTY</b> and the result shows that only one T exists in the solution.
</p>

<?php } else { ?>
<p>
Welcome to wordle.
</p>
<?php
$game=getBlankGame(mt_rand(1,count($solutions)));
?>
<br />
<p><a href="<?=$url;?>?state=<?=urlencode(encrypt($game));?>">New Game</a></p>
<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 } ?>
<?php } ?>
</body>
</html>